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.
This commit is contained in:
777genius 2026-04-15 16:18:11 +03:00
parent 363fef224d
commit aed08113e6
52 changed files with 7258 additions and 1027 deletions

View file

@ -1,3 +1,5 @@
import { STABLE_SLOT_GEOMETRY } from '../layout/stableSlotGeometry';
/** /**
* Canvas rendering constants for the agent graph visualization. * Canvas rendering constants for the agent graph visualization.
* Adapted from agent-flow's canvas-constants.ts (Apache 2.0). * Adapted from agent-flow's canvas-constants.ts (Apache 2.0).
@ -262,8 +264,8 @@ export const KANBAN_ZONE = {
rowHeight: 46, rowHeight: 46,
/** Zone starts this far below member node center */ /** Zone starts this far below member node center */
offsetY: 70, offsetY: 70,
/** Column order: todo → wip → done → review → approved */ /** Column sequence: pending → wip → done → review → approved */
columns: ['todo', 'wip', 'done', 'review', 'approved'] as const, columns: ['todo', 'wip', 'done', 'review', 'approved'] as const,
/** Max tasks shown per column (overflow hidden) */ /** Max tasks shown per column (overflow hidden) */
maxVisibleRows: 6, maxVisibleRows: STABLE_SLOT_GEOMETRY.taskMaxVisibleRows,
} as const; } as const;

View file

@ -33,9 +33,9 @@ export function useGraphInteraction(
clickedNodeId.current = hit; clickedNodeId.current = hit;
if (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); const hitNode = nodes.find((n) => n.id === hit);
if (hitNode && (hitNode.kind === 'member' || hitNode.kind === 'lead')) { if (hitNode?.kind === 'member') {
dragNodeId.current = hit; dragNodeId.current = hit;
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,7 @@
// ─── Components ────────────────────────────────────────────────────────────── // ─── Components ──────────────────────────────────────────────────────────────
export { GraphView } from './ui/GraphView'; export { GraphView } from './ui/GraphView';
export type { GraphViewProps } 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) ───────────────────────── // ─── Port Interfaces (for adapters in host project) ─────────────────────────
export type { GraphDataPort } from './ports/GraphDataPort'; export type { GraphDataPort } from './ports/GraphDataPort';
@ -21,6 +22,9 @@ export type {
GraphEdge, GraphEdge,
GraphParticle, GraphParticle,
GraphActivityItem, GraphActivityItem,
GraphOwnerSlotAssignment,
GraphLayoutPort,
GraphLayoutVersion,
GraphNodeKind, GraphNodeKind,
GraphNodeState, GraphNodeState,
GraphLaunchVisualState, GraphLaunchVisualState,

View file

@ -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 type { GraphActivityItem, GraphNode } from '../ports/types';
import { createStableSlotActivityLane } from './stableSlotGeometry';
export const ACTIVITY_LANE = { const STABLE_SLOT_ACTIVITY = createStableSlotActivityLane({
width: 296, nodeMetrics: {
itemHeight: 72, radiusLead: NODE.radiusLead,
rowHeight: 80, radiusMember: NODE.radiusMember,
maxVisibleItems: 3, },
headerHeight: 20, zoomRange: {
overflowHeight: 32, minZoom: CAMERA.minZoom,
horizontalGapLead: 76, maxZoom: CAMERA.maxZoom,
horizontalGapMember: 84, },
ownerClearanceLead: 92, });
ownerClearanceMember: 104,
viewportPadding: 12,
visiblePadding: 80,
minScale: CAMERA.minZoom,
maxScale: CAMERA.maxZoom,
} as const;
const RESERVED_HEIGHT = export const ACTIVITY_LANE = STABLE_SLOT_ACTIVITY.lane;
ACTIVITY_LANE.headerHeight export const ACTIVITY_ANCHOR_LAYOUT = STABLE_SLOT_ACTIVITY.anchor;
+ 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 interface ActivityLaneWindow { export interface ActivityLaneWindow {
items: GraphActivityItem[]; items: GraphActivityItem[];
@ -226,29 +209,14 @@ function packActivityLaneRects<T extends {
groupBySide = true groupBySide = true
): Map<string, { x: number; y: number }> { ): Map<string, { x: number; y: number }> {
const placements = new Map<string, { x: number; y: number }>(); const placements = new Map<string, { x: number; y: number }>();
for (const side of resolvePackedActivitySides(groupBySide)) {
const sideGroups = groupBySide ? (['left', 'right'] as const) : (['left'] as const);
for (const side of sideGroups) {
const sideRects = rects const sideRects = rects
.filter((rect) => !groupBySide || rect.side === side) .filter((rect) => !groupBySide || rect.side === side)
.sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y)); .sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y));
const placed: Array<ActivityLaneScreenRect & { placedY: number }> = []; const placed: (T & { placedY: number })[] = [];
for (const rect of sideRects) { for (const rect of sideRects) {
let placedY = rect.y; const placedY = resolvePackedActivityY(rect, placed, gap);
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;
}
}
placed.push({ ...rect, placedY }); placed.push({ ...rect, placedY });
placements.set(rect.id, { x: rect.x, y: 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) { for (let index = 0; index < items.length; index += 1) {
const itemTop = itemsTop + index * ACTIVITY_LANE.rowHeight; const itemTop = itemsTop + index * ACTIVITY_LANE.rowHeight;
const item = items.at(index);
if (!item) {
continue;
}
if ( if (
worldX >= left && worldX >= left &&
worldX <= left + ACTIVITY_LANE.width && worldX <= left + ACTIVITY_LANE.width &&
worldY >= itemTop && worldY >= itemTop &&
worldY <= itemTop + ACTIVITY_LANE.itemHeight 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 { function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
return aStart < bEnd && aEnd > bStart; return aStart < bEnd && aEnd > bStart;
} }
function resolvePackedActivitySides(groupBySide: boolean): readonly ActivityLaneSide[] {
return groupBySide ? ['left', 'right'] : ['left'];
}
function resolvePackedActivityY<T extends {
x: number;
y: number;
width: number;
height: number;
}>(
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;
}

View file

@ -12,6 +12,7 @@ import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants';
import { COLORS } from '../constants/colors'; import { COLORS } from '../constants/colors';
import { resolveActivityLaneSide } from './activityLane'; import { resolveActivityLaneSide } from './activityLane';
import type { ActivityLaneWorldBounds } from './activityLane'; import type { ActivityLaneWorldBounds } from './activityLane';
import type { SlotFrame, StableRect } from './stableSlots';
/** Column header info for rendering */ /** Column header info for rendering */
export interface KanbanColumnHeader { export interface KanbanColumnHeader {
@ -81,7 +82,7 @@ export class KanbanLayoutEngine {
static readonly #colTasks = new Map<string, GraphNode[]>(); static readonly #colTasks = new Map<string, GraphNode[]>();
/** Zone info for rendering column headers — updated each layout() call */ /** 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. * Position all task nodes in kanban columns relative to their owner.
@ -89,22 +90,42 @@ export class KanbanLayoutEngine {
*/ */
static layout( static layout(
nodes: GraphNode[], nodes: GraphNode[],
options?: { activityLaneBounds?: readonly ActivityLaneWorldBounds[] } options?: {
activityLaneBounds?: readonly ActivityLaneWorldBounds[];
memberSlotFrames?: readonly SlotFrame[];
unassignedTaskRect?: StableRect | null;
}
): void { ): void {
const nodeMap = this.#nodeMap; const nodeMap = this.#nodeMap;
nodeMap.clear(); nodeMap.clear();
for (const n of nodes) nodeMap.set(n.id, n); for (const n of nodes) nodeMap.set(n.id, n);
const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null; const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null;
const activityLaneBounds = options?.activityLaneBounds ?? []; const activityLaneBounds = options?.activityLaneBounds ?? [];
const memberSlotFrameByOwnerId = new Map(
(options?.memberSlotFrames ?? []).map((frame) => [frame.ownerId, frame] as const)
);
const tasksByOwner = this.#tasksByOwner; const tasksByOwner = this.#tasksByOwner;
tasksByOwner.clear(); tasksByOwner.clear();
const unassigned = this.#unassigned; const unassigned = this.#unassigned;
unassigned.length = 0; 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) { for (const n of nodes) {
if (n.kind !== 'task') continue; if (n.kind !== 'task') continue;
if (n.ownerId) { if (n.ownerId && hasLayoutOwner(n.ownerId)) {
let group = tasksByOwner.get(n.ownerId); let group = tasksByOwner.get(n.ownerId);
if (!group) { if (!group) {
group = []; group = [];
@ -117,22 +138,23 @@ export class KanbanLayoutEngine {
} }
// Reset zones // Reset zones
this.zones = []; this.zones.length = 0;
for (const [ownerId, tasks] of tasksByOwner) { for (const [ownerId, tasks] of tasksByOwner) {
const owner = nodeMap.get(ownerId); 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( const zoneInfo = KanbanLayoutEngine.#layoutZone(
tasks, tasks,
owner, owner,
ownerId, ownerId,
leadX, leadX,
activityLaneBounds activityLaneBounds,
memberSlotFrameByOwnerId.get(ownerId) ?? null
); );
if (zoneInfo) this.zones.push(zoneInfo); if (zoneInfo) this.zones.push(zoneInfo);
} }
KanbanLayoutEngine.#layoutUnassigned(unassigned, nodes); KanbanLayoutEngine.#layoutUnassigned(unassigned, nodes, options?.unassignedTaskRect ?? null);
} }
// ─── Private ────────────────────────────────────────────────────────────── // ─── Private ──────────────────────────────────────────────────────────────
@ -142,7 +164,8 @@ export class KanbanLayoutEngine {
owner: GraphNode, owner: GraphNode,
ownerId: string, ownerId: string,
leadX: number | null, leadX: number | null,
activityLaneBounds: readonly ActivityLaneWorldBounds[] activityLaneBounds: readonly ActivityLaneWorldBounds[],
slotFrame: SlotFrame | null
): KanbanZoneInfo | null { ): KanbanZoneInfo | null {
const { columnWidth, rowHeight, offsetY, columns } = KANBAN_ZONE; const { columnWidth, rowHeight, offsetY, columns } = KANBAN_ZONE;
const headerHeight = 20; // space for column header label 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. // 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. // This makes member lanes reserve real visual space instead of only affecting the force layout.
const baseX = getOwnerKanbanBaseX({ let baseX = getOwnerKanbanBaseX({
ownerX, ownerX,
ownerKind: owner.kind, ownerKind: owner.kind,
activeColumnCount: activeColumns.length, activeColumnCount: activeColumns.length,
columnWidth, columnWidth,
leadX, leadX,
}); });
const taskZoneLeft = baseX - TASK_PILL.width / 2; let baseY: number;
const taskZoneRight =
baseX + (activeColumns.length - 1) * columnWidth + TASK_PILL.width / 2; if (slotFrame) {
const overlappingActivityBottom = activityLaneBounds.reduce((maxBottom, bounds) => { baseX = slotFrame.taskBandRect.left + TASK_PILL.width / 2;
if (bounds.ownerId === ownerId) { 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); return Math.max(maxBottom, bounds.bottom);
} }, -Infinity);
if (!rangesOverlap(taskZoneLeft, taskZoneRight, bounds.left, bounds.right)) { baseY = Math.max(
return maxBottom; ownerY + offsetY,
} overlappingActivityBottom > -Infinity
return Math.max(maxBottom, bounds.bottom); ? overlappingActivityBottom + ACTIVITY_KANBAN_CLEARANCE
}, -Infinity); : -Infinity
const baseY = Math.max( );
ownerY + offsetY, }
overlappingActivityBottom > -Infinity
? overlappingActivityBottom + ACTIVITY_KANBAN_CLEARANCE
: -Infinity
);
// Build headers + position tasks // Build headers + position tasks
const headers: KanbanColumnHeader[] = []; const headers: KanbanColumnHeader[] = [];
@ -221,8 +251,8 @@ export class KanbanLayoutEngine {
for (const [rowIdx, task] of col.tasks.entries()) { for (const [rowIdx, task] of col.tasks.entries()) {
const targetX = colX; const targetX = colX;
const targetY = baseY + headerHeight + rowIdx * rowHeight; const targetY = baseY + headerHeight + rowIdx * rowHeight;
task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX; task.x = slotFrame ? targetX : 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.y = slotFrame ? targetY : task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY;
task.fx = task.x; task.fx = task.x;
task.fy = task.y; task.fy = task.y;
task.vx = 0; 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; if (tasks.length === 0) return;
const { columnWidth, rowHeight } = KANBAN_ZONE; 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) // Find the lowest Y of ALL positioned nodes (members + their owned tasks)
let sumX = 0; let sumX = 0;
let maxY = -Infinity; let maxY = -Infinity;

View file

@ -3,6 +3,7 @@ import {
ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_ANCHOR_LAYOUT,
resolveActivityLaneSide, resolveActivityLaneSide,
} from './activityLane'; } from './activityLane';
import { createStableSlotLaunchAnchorLayout } from './stableSlotGeometry';
export interface WorldBounds { export interface WorldBounds {
left: number; left: number;
@ -18,17 +19,9 @@ export interface LaunchAnchorScreenPlacement {
visible: boolean; visible: boolean;
} }
export const LAUNCH_ANCHOR_LAYOUT = { export const LAUNCH_ANCHOR_LAYOUT = createStableSlotLaunchAnchorLayout({
compactWidth: 336, radiusLead: NODE.radiusLead,
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;
const LAUNCH_ANCHOR_PREFIX = '__launch_anchor__:'; const LAUNCH_ANCHOR_PREFIX = '__launch_anchor__:';
const ACTIVITY_ANCHOR_PREFIX = '__activity_anchor__:'; const ACTIVITY_ANCHOR_PREFIX = '__activity_anchor__:';

View file

@ -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<StableSlotNodeMetrics, 'radiusLead'>
): 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,
};
}

File diff suppressed because it is too large Load diff

View file

@ -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. * Data provider port supplies graph state to the visualization.
@ -17,4 +17,6 @@ export interface GraphDataPort {
teamColor?: string; teamColor?: string;
/** Whether the team lead process is alive */ /** Whether the team lead process is alive */
isAlive?: boolean; isAlive?: boolean;
/** Stable owner-slot layout hints supplied by the host app */
layout?: GraphLayoutPort;
} }

View file

@ -5,9 +5,13 @@ export type {
GraphNode, GraphNode,
GraphEdge, GraphEdge,
GraphParticle, GraphParticle,
GraphActivityItem,
GraphNodeKind, GraphNodeKind,
GraphNodeState, GraphNodeState,
GraphEdgeType, GraphEdgeType,
GraphParticleKind, GraphParticleKind,
GraphDomainRef, GraphDomainRef,
GraphOwnerSlotAssignment,
GraphLayoutPort,
GraphLayoutVersion,
} from './types'; } from './types';

View file

@ -48,6 +48,19 @@ export interface GraphActivityItem {
authorLabel?: string; authorLabel?: string;
} }
export type GraphLayoutVersion = 'stable-slots-v1';
export interface GraphOwnerSlotAssignment {
ringIndex: number;
sectorIndex: number;
}
export interface GraphLayoutPort {
version: GraphLayoutVersion;
ownerOrder: string[];
slotAssignments: Record<string, GraphOwnerSlotAssignment>;
}
// ─── Graph Node ────────────────────────────────────────────────────────────── // ─── Graph Node ──────────────────────────────────────────────────────────────
export interface GraphNode { export interface GraphNode {

View file

@ -13,6 +13,8 @@ import {
EyeOff, EyeOff,
Maximize2, Maximize2,
Pause, Pause,
PanelLeftClose,
PanelLeftOpen,
Pin, Pin,
Play, Play,
Plus, Plus,
@ -41,6 +43,8 @@ export interface GraphControlsProps {
onRequestFullscreen?: () => void; onRequestFullscreen?: () => void;
onOpenTeamPage?: () => void; onOpenTeamPage?: () => void;
onCreateTask?: () => void; onCreateTask?: () => void;
onToggleSidebar?: () => void;
isSidebarVisible?: boolean;
teamName: string; teamName: string;
teamColor?: string; teamColor?: string;
isAlive?: boolean; isAlive?: boolean;
@ -60,6 +64,8 @@ export function GraphControls({
onRequestFullscreen, onRequestFullscreen,
onOpenTeamPage, onOpenTeamPage,
onCreateTask, onCreateTask,
onToggleSidebar,
isSidebarVisible = true,
teamColor, teamColor,
}: GraphControlsProps): React.JSX.Element { }: GraphControlsProps): React.JSX.Element {
const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false);
@ -100,6 +106,28 @@ export function GraphControls({
return ( return (
<> <>
<div className="absolute left-3 top-3 z-20 flex items-center gap-0.5 pointer-events-none"> <div className="absolute left-3 top-3 z-20 flex items-center gap-0.5 pointer-events-none">
{onToggleSidebar ? (
<div
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: `1px solid ${nameColor}25`,
}}
>
<ToolbarButton
onClick={onToggleSidebar}
icon={
isSidebarVisible ? (
<PanelLeftClose size={TOPBAR_ICON_SIZE} />
) : (
<PanelLeftOpen size={TOPBAR_ICON_SIZE} />
)
}
toolbar
title={isSidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
/>
</div>
) : null}
{onOpenTeamPage ? ( {onOpenTeamPage ? (
<div <div
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm" className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"

View file

@ -15,8 +15,8 @@ import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/d
import type { GraphDataPort } from '../ports/GraphDataPort'; import type { GraphDataPort } from '../ports/GraphDataPort';
import type { GraphEventPort } from '../ports/GraphEventPort'; import type { GraphEventPort } from '../ports/GraphEventPort';
import type { GraphConfigPort } from '../ports/GraphConfigPort'; import type { GraphConfigPort } from '../ports/GraphConfigPort';
import type { GraphEdge, GraphNode } from '../ports/types'; import type { GraphEdge, GraphNode, GraphOwnerSlotAssignment } from '../ports/types';
import { GraphCanvas, type GraphCanvasHandle, type GraphDrawState } from './GraphCanvas'; import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas';
import { GraphControls, type GraphFilterState } from './GraphControls'; import { GraphControls, type GraphFilterState } from './GraphControls';
import { GraphOverlay } from './GraphOverlay'; import { GraphOverlay } from './GraphOverlay';
import { GraphEdgeOverlay } from './GraphEdgeOverlay'; import { GraphEdgeOverlay } from './GraphEdgeOverlay';
@ -45,6 +45,14 @@ export interface GraphViewProps {
onRequestFullscreen?: () => void; onRequestFullscreen?: () => void;
onOpenTeamPage?: () => void; onOpenTeamPage?: () => void;
onCreateTask?: () => 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. */ /** Custom overlay renderer — replaces built-in GraphOverlay. Allows host app to reuse its own components. */
renderOverlay?: (props: { renderOverlay?: (props: {
node: GraphNode; node: GraphNode;
@ -90,6 +98,9 @@ export function GraphView({
onRequestFullscreen, onRequestFullscreen,
onOpenTeamPage, onOpenTeamPage,
onCreateTask, onCreateTask,
onToggleSidebar,
isSidebarVisible = true,
onOwnerSlotDrop,
renderOverlay, renderOverlay,
renderEdgeOverlay, renderEdgeOverlay,
renderHud, 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<string>): 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 ──────────────────────────────── // ─── Sync data from adapter → simulation ────────────────────────────────
useEffect(() => { useEffect(() => {
const filteredNodes = data.nodes.filter((n) => { simulation.updateData(data.nodes, data.edges, data.particles, data.teamName, data.layout);
if (n.kind === 'task' && !filters.showTasks) return false; }, [data, simulation]);
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]);
// ─── UNIFIED RAF LOOP: tick simulation + draw canvas ──────────────────── // ─── UNIFIED RAF LOOP: tick simulation + draw canvas ────────────────────
const focusState = useMemo( const focusState = useMemo(
@ -247,7 +271,7 @@ export function GraphView({
return null; return null;
} }
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId); const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
if (!node || node.x == null || node.y == null) { if (node?.x == null || node?.y == null) {
return null; return null;
} }
const transform = cameraRef.current.transformRef.current; const transform = cameraRef.current.transformRef.current;
@ -261,7 +285,7 @@ export function GraphView({
}, [getViewportSize]); }, [getViewportSize]);
const getNodeWorldPosition = useCallback((nodeId: string) => { const getNodeWorldPosition = useCallback((nodeId: string) => {
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId); const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
if (!node || node.x == null || node.y == null) { if (node?.x == null || node?.y == null) {
return null; return null;
} }
return { x: node.x, y: node.y }; 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. // 3. Draw every frame: background stars and shooting stars need continuous motion.
const state = simulationRef.current.stateRef.current; 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) // 4. Draw canvas imperatively (NO React re-render)
canvasHandle.current?.draw({ canvasHandle.current?.draw({
teamName: data.teamName, teamName: data.teamName,
nodes: state.nodes, nodes: visibleNodes,
edges: state.edges, edges: visibleEdges,
particles: state.particles, particles: state.particles,
effects: state.effects, effects: state.effects,
time: state.time, time: state.time,
@ -304,8 +331,14 @@ export function GraphView({
}); });
rafRef.current = requestAnimationFrame(animate); 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 // Start/stop RAF
useEffect(() => { useEffect(() => {
@ -401,8 +434,9 @@ export function GraphView({
if (!canvas) return; if (!canvas) return;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
const nodes = simulation.stateRef.current.nodes; const nodes = getVisibleNodes(simulation.stateRef.current.nodes);
const edges = simulation.stateRef.current.edges; const visibleNodeIds = new Set(nodes.map((node) => node.id));
const edges = getVisibleEdges(simulation.stateRef.current.edges, visibleNodeIds);
const nodeMap = getNodeMap(nodes); const nodeMap = getNodeMap(nodes);
const interactiveEdges = getInteractiveEdges(canvas, nodes, edges); 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( const handleMouseMove = useCallback(
@ -448,7 +491,7 @@ export function GraphView({
if (!canvas) return; if (!canvas) return;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); 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; return;
} }
@ -457,8 +500,9 @@ export function GraphView({
if (!canvas) return; if (!canvas) return;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
const nodes = simulation.stateRef.current.nodes; const nodes = getVisibleNodes(simulation.stateRef.current.nodes);
const edges = simulation.stateRef.current.edges; 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); const hoveredNodeId = findNodeAt(world.x, world.y, nodes);
interaction.hoveredNodeId.current = hoveredNodeId; interaction.hoveredNodeId.current = hoveredNodeId;
@ -474,11 +518,14 @@ export function GraphView({
hoveredEdgeIdRef.current = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap); hoveredEdgeIdRef.current = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap);
canvas.style.cursor = hoveredEdgeIdRef.current ? 'pointer' : 'grab'; canvas.style.cursor = hoveredEdgeIdRef.current ? 'pointer' : 'grab';
}, },
[camera, getInteractiveEdges, getNodeMap, interaction, simulation.stateRef] [camera, getInteractiveEdges, getNodeMap, getVisibleEdges, getVisibleNodes, interaction, simulation.stateRef]
); );
const handleMouseUp = useCallback( const handleMouseUp = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
const draggedNodeId = interaction.dragNodeId.current;
const wasDragging = interaction.isDragging.current;
if (isPanningRef.current) { if (isPanningRef.current) {
camera.handlePanEnd(); camera.handlePanEnd();
isPanningRef.current = false; isPanningRef.current = false;
@ -489,6 +536,33 @@ export function GraphView({
} }
const clickedId = interaction.handleMouseUp(); 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) { if (clickedId) {
setSelectedNodeId(clickedId); setSelectedNodeId(clickedId);
setSelectedEdgeId(null); 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( const handleDoubleClick = useCallback(
@ -538,7 +612,7 @@ export function GraphView({
const nodeId = interaction.handleDoubleClick( const nodeId = interaction.handleDoubleClick(
world.x, world.x,
world.y, world.y,
simulation.stateRef.current.nodes getVisibleNodes(simulation.stateRef.current.nodes)
); );
if (nodeId) { if (nodeId) {
setSelectedEdgeId(null); setSelectedEdgeId(null);
@ -553,7 +627,7 @@ export function GraphView({
} }
} }
}, },
[camera, interaction, simulation.stateRef, events] [camera, events, getVisibleNodes, interaction, simulation.stateRef]
); );
// ─── Keyboard ─────────────────────────────────────────────────────────── // ─── Keyboard ───────────────────────────────────────────────────────────
@ -598,10 +672,6 @@ export function GraphView({
const selectedEdge: GraphEdge | null = selectedEdgeId const selectedEdge: GraphEdge | null = selectedEdgeId
? (simulation.stateRef.current.edges.find((edge) => edge.id === selectedEdgeId) ?? null) ? (simulation.stateRef.current.edges.find((edge) => edge.id === selectedEdgeId) ?? null)
: null; : null;
const hasBlockingEdges = useMemo(
() => data.edges.some((edge) => edge.type === 'blocking'),
[data.edges]
);
const selectedEdgeNodeMap = useMemo( const selectedEdgeNodeMap = useMemo(
() => getNodeMap(simulation.stateRef.current.nodes), () => getNodeMap(simulation.stateRef.current.nodes),
[data.nodes, getNodeMap, selectedEdgeId, simulation.stateRef] [data.nodes, getNodeMap, selectedEdgeId, simulation.stateRef]
@ -719,6 +789,8 @@ export function GraphView({
onRequestFullscreen={onRequestFullscreen} onRequestFullscreen={onRequestFullscreen}
onOpenTeamPage={onOpenTeamPage} onOpenTeamPage={onOpenTeamPage}
onCreateTask={onCreateTask} onCreateTask={onCreateTask}
onToggleSidebar={onToggleSidebar}
isSidebarVisible={isSidebarVisible}
teamName={data.teamName} teamName={data.teamName}
teamColor={data.teamColor} teamColor={data.teamColor}
isAlive={data.isAlive} isAlive={data.isAlive}

View file

@ -5,6 +5,7 @@ This feature is a thin renderer slice over the reusable graph engine in `package
Read first: Read first:
- [Feature Architecture Standard](../../docs/FEATURE_ARCHITECTURE_STANDARD.md) - [Feature Architecture Standard](../../docs/FEATURE_ARCHITECTURE_STANDARD.md)
- [Feature root guidance](../CLAUDE.md) - [Feature root guidance](../CLAUDE.md)
- [Stable Slot Layout Plan](./STABLE_SLOT_LAYOUT_PLAN.md)
Public entrypoint: Public entrypoint:
- `@features/agent-graph/renderer` - `@features/agent-graph/renderer`

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,8 @@ import { getIdleGraphLabel } from '@shared/utils/idleNotificationSemantics';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { buildGraphMemberNodeIdForMember } from './graphOwnerIdentity';
import type { GraphActivityItem } from '@claude-teams/agent-graph'; import type { GraphActivityItem } from '@claude-teams/agent-graph';
import type { import type {
AttachmentMeta, AttachmentMeta,
@ -18,6 +20,8 @@ export interface InlineActivityEntry {
ownerNodeId: string; ownerNodeId: string;
graphItem: GraphActivityItem; graphItem: GraphActivityItem;
message: InboxMessage; message: InboxMessage;
sourceKind: 'message' | 'comment';
sourceOrder: number | null;
} }
export interface ActivityEntrySourceData { export interface ActivityEntrySourceData {
@ -49,6 +53,11 @@ export function buildInlineActivityEntries({
ownerNodeIds, ownerNodeIds,
}: BuildInlineActivityEntriesArgs): Map<string, InlineActivityEntry[]> { }: BuildInlineActivityEntriesArgs): Map<string, InlineActivityEntry[]> {
const entriesByOwnerNodeId = new Map<string, InlineActivityEntry[]>(); const entriesByOwnerNodeId = new Map<string, InlineActivityEntry[]>();
const memberNodeIdByName = new Map(
data.members
.filter((member) => !isLeadMember(member))
.map((member) => [member.name, buildGraphMemberNodeIdForMember(teamName, member)] as const)
);
const appendEntry = (entry: InlineActivityEntry): void => { const appendEntry = (entry: InlineActivityEntry): void => {
const targetOwnerNodeId = ownerNodeIds.has(entry.ownerNodeId) ? entry.ownerNodeId : leadId; 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 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) { for (const message of orderedMessages) {
if (message.summary?.startsWith('Comment on ')) { if (message.summary?.startsWith('Comment on ')) {
continue; continue;
@ -80,10 +92,10 @@ export function buildInlineActivityEntries({
const ownerNodeId = resolveMessageOwnerNodeId({ const ownerNodeId = resolveMessageOwnerNodeId({
message, message,
teamName,
leadId, leadId,
leadName, leadName,
ownerNodeIds, ownerNodeIds,
memberNodeIdByName,
}); });
if (!ownerNodeId) { if (!ownerNodeId) {
continue; continue;
@ -113,6 +125,8 @@ export function buildInlineActivityEntries({
ownerNodeId, ownerNodeId,
graphItem, graphItem,
message, message,
sourceKind: 'message',
sourceOrder: messageSourceOrderByKey.get(getActivityMessageKey(message)) ?? null,
}); });
} }
@ -123,10 +137,10 @@ export function buildInlineActivityEntries({
const ownerNodeId = resolveCommentOwnerNodeId({ const ownerNodeId = resolveCommentOwnerNodeId({
taskOwner: item.task.owner, taskOwner: item.task.owner,
author: item.comment.author, author: item.comment.author,
teamName,
leadId, leadId,
leadName, leadName,
ownerNodeIds, ownerNodeIds,
memberNodeIdByName,
}); });
if (!ownerNodeId) { if (!ownerNodeId) {
continue; continue;
@ -154,14 +168,13 @@ export function buildInlineActivityEntries({
task: item.task, task: item.task,
comment: item.comment, comment: item.comment,
}), }),
sourceKind: 'comment',
sourceOrder: item.sourceOrder,
}); });
} }
for (const [ownerNodeId, entries] of entriesByOwnerNodeId) { for (const [ownerNodeId, entries] of entriesByOwnerNodeId) {
entriesByOwnerNodeId.set( entriesByOwnerNodeId.set(ownerNodeId, entries.toSorted(compareInlineActivityEntries));
ownerNodeId,
entries.toSorted((a, b) => b.graphItem.timestamp.localeCompare(a.graphItem.timestamp))
);
} }
return entriesByOwnerNodeId; return entriesByOwnerNodeId;
@ -169,30 +182,55 @@ export function buildInlineActivityEntries({
function collectTaskComments( function collectTaskComments(
tasks: readonly TeamTaskWithKanban[] tasks: readonly TeamTaskWithKanban[]
): { task: TeamTaskWithKanban; comment: TaskComment }[] { ): { task: TeamTaskWithKanban; comment: TaskComment; sourceOrder: number }[] {
const items: { task: TeamTaskWithKanban; comment: TaskComment }[] = []; const items: { task: TeamTaskWithKanban; comment: TaskComment; sourceOrder: number }[] = [];
let sourceOrder = 0;
for (const task of tasks) { for (const task of tasks) {
for (const comment of task.comments ?? []) { for (const comment of task.comments ?? []) {
items.push({ task, comment }); items.push({ task, comment, sourceOrder });
sourceOrder += 1;
} }
} }
return items; 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: { function resolveMessageOwnerNodeId(args: {
message: InboxMessage; message: InboxMessage;
teamName: string;
leadId: string; leadId: string;
leadName: string; leadName: string;
ownerNodeIds: ReadonlySet<string>; ownerNodeIds: ReadonlySet<string>;
memberNodeIdByName: ReadonlyMap<string, string>;
}): string | null { }): 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') { if (message.source === 'cross_team' || message.source === 'cross_team_sent') {
return leadId; return leadId;
} }
const fromId = resolveParticipantId(message.from ?? '', teamName, leadId, leadName); const fromId = resolveParticipantId(message.from ?? '', leadId, leadName, memberNodeIdByName);
const toId = message.to ? resolveParticipantId(message.to, teamName, leadId, leadName) : leadId; const toId = message.to
? resolveParticipantId(message.to, leadId, leadName, memberNodeIdByName)
: leadId;
if (toId !== leadId && ownerNodeIds.has(toId)) { if (toId !== leadId && ownerNodeIds.has(toId)) {
return toId; return toId;
@ -206,20 +244,20 @@ function resolveMessageOwnerNodeId(args: {
function resolveCommentOwnerNodeId(args: { function resolveCommentOwnerNodeId(args: {
taskOwner: string | undefined; taskOwner: string | undefined;
author: string; author: string;
teamName: string;
leadId: string; leadId: string;
leadName: string; leadName: string;
ownerNodeIds: ReadonlySet<string>; ownerNodeIds: ReadonlySet<string>;
memberNodeIdByName: ReadonlyMap<string, string>;
}): string | null { }): string | null {
const { taskOwner, author, teamName, leadId, leadName, ownerNodeIds } = args; const { taskOwner, author, leadId, leadName, ownerNodeIds, memberNodeIdByName } = args;
if (taskOwner) { if (taskOwner) {
const ownerId = resolveParticipantId(taskOwner, teamName, leadId, leadName); const ownerId = resolveParticipantId(taskOwner, leadId, leadName, memberNodeIdByName);
if (ownerNodeIds.has(ownerId)) { if (ownerNodeIds.has(ownerId)) {
return ownerId; return ownerId;
} }
} }
const authorId = resolveParticipantId(author, teamName, leadId, leadName); const authorId = resolveParticipantId(author, leadId, leadName, memberNodeIdByName);
if (ownerNodeIds.has(authorId)) { if (ownerNodeIds.has(authorId)) {
return authorId; return authorId;
} }
@ -327,9 +365,9 @@ function getActivityMessageKey(message: InboxMessage): string {
function resolveParticipantId( function resolveParticipantId(
name: string, name: string,
teamName: string,
leadId: string, leadId: string,
leadName?: string leadName: string | undefined,
memberNodeIdByName: ReadonlyMap<string, string>
): string { ): string {
const normalized = name.trim().toLowerCase(); const normalized = name.trim().toLowerCase();
if (normalized === 'user' || normalized === 'team-lead') { if (normalized === 'user' || normalized === 'team-lead') {
@ -338,7 +376,7 @@ function resolveParticipantId(
if (normalized === leadName?.trim().toLowerCase()) { if (normalized === leadName?.trim().toLowerCase()) {
return leadId; return leadId;
} }
return `member:${teamName}:${name}`; return memberNodeIdByName.get(name) ?? leadId;
} }
function buildParticipantLabel(name: string | undefined, leadName: string): string { function buildParticipantLabel(name: string | undefined, leadName: string): string {

View file

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

View file

@ -29,6 +29,11 @@ import {
getGraphLeadMemberName, getGraphLeadMemberName,
} from '../../core/domain/buildInlineActivityEntries'; } from '../../core/domain/buildInlineActivityEntries';
import { collapseOverflowStacksWithMeta } from '../../core/domain/collapseOverflowStacks'; import { collapseOverflowStacksWithMeta } from '../../core/domain/collapseOverflowStacks';
import {
buildGraphMemberNodeIdForMember,
getGraphStableOwnerId,
GRAPH_STABLE_SLOT_LAYOUT_VERSION,
} from '../../core/domain/graphOwnerIdentity';
import { import {
isTaskBlocked, isTaskBlocked,
isTaskInReviewCycle, isTaskInReviewCycle,
@ -38,8 +43,10 @@ import {
import type { import type {
GraphDataPort, GraphDataPort,
GraphEdge, GraphEdge,
GraphLayoutPort,
GraphNode, GraphNode,
GraphNodeState, GraphNodeState,
GraphOwnerSlotAssignment,
GraphParticle, GraphParticle,
} from '@claude-teams/agent-graph'; } from '@claude-teams/agent-graph';
import type { import type {
@ -49,6 +56,7 @@ import type {
MemberSpawnStatusEntry, MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot, MemberSpawnStatusesSnapshot,
TeamData, TeamData,
TeamProcess,
TeamProvisioningProgress, TeamProvisioningProgress,
} from '@shared/types/team'; } from '@shared/types/team';
import type { LeadContextUsage } from '@shared/types/team'; import type { LeadContextUsage } from '@shared/types/team';
@ -90,12 +98,23 @@ export class TeamGraphAdapter {
toolHistory?: Record<string, ActiveToolCall[]>, toolHistory?: Record<string, ActiveToolCall[]>,
commentReadState?: Record<string, unknown>, commentReadState?: Record<string, unknown>,
provisioningProgress?: TeamProvisioningProgress | null, provisioningProgress?: TeamProvisioningProgress | null,
memberSpawnSnapshot?: MemberSpawnStatusesSnapshot memberSpawnSnapshot?: MemberSpawnStatusesSnapshot,
slotAssignments?: Record<string, GraphOwnerSlotAssignment>
): GraphDataPort { ): GraphDataPort {
if (teamData?.teamName !== teamName) { if (teamData?.teamName !== teamName) {
return TeamGraphAdapter.#emptyResult(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 // Reset particle tracking when team changes
if (teamName !== this.#lastTeamName) { if (teamName !== this.#lastTeamName) {
this.#seenMessageIds.clear(); this.#seenMessageIds.clear();
@ -115,6 +134,7 @@ export class TeamGraphAdapter {
const leadId = `lead:${teamName}`; const leadId = `lead:${teamName}`;
const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName); const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName);
const memberNodeIdByName = TeamGraphAdapter.#buildMemberNodeIdByName(teamData, teamName);
const provisioningPresentation = buildTeamProvisioningPresentation({ const provisioningPresentation = buildTeamProvisioningPresentation({
progress: provisioningProgress, progress: provisioningProgress,
members: teamData.members, members: teamData.members,
@ -144,6 +164,7 @@ export class TeamGraphAdapter {
leadId, leadId,
teamData, teamData,
teamName, teamName,
memberNodeIdByName,
spawnStatuses, spawnStatuses,
pendingApprovalAgents, pendingApprovalAgents,
activeTools, activeTools,
@ -152,8 +173,8 @@ export class TeamGraphAdapter {
isTeamProvisioning, isTeamProvisioning,
isLaunchSettling isLaunchSettling
); );
this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState); this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState, memberNodeIdByName);
this.#buildProcessNodes(nodes, edges, teamData, teamName); this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByName);
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName); this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
this.#buildMessageParticles( this.#buildMessageParticles(
particles, particles,
@ -162,9 +183,18 @@ export class TeamGraphAdapter {
teamName, teamName,
leadId, leadId,
leadName, leadName,
edges edges,
memberNodeIdByName
);
this.#buildCommentParticles(
particles,
teamData,
teamName,
leadId,
leadName,
edges,
memberNodeIdByName
); );
this.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges);
return { return {
nodes, nodes,
@ -173,6 +203,7 @@ export class TeamGraphAdapter {
teamName, teamName,
teamColor: teamData.config.color ?? undefined, teamColor: teamData.config.color ?? undefined,
isAlive: teamData.isAlive, isAlive: teamData.isAlive,
layout: TeamGraphAdapter.#buildLayoutPort(teamData, teamName, slotAssignments),
}; };
} }
@ -195,6 +226,115 @@ export class TeamGraphAdapter {
return getGraphLeadMemberName(data, teamName); return getGraphLeadMemberName(data, teamName);
} }
static #buildMemberNodeIdByName(data: TeamData, teamName: string): Map<string, string> {
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<string, GraphOwnerSlotAssignment>
): GraphLayoutPort {
const ownerOrder: string[] = [];
const seenOwnerNodeIds = new Set<string>();
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<string, GraphOwnerSlotAssignment> = {};
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<string, number>();
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 { static #isBeforeParticleCutoff(timestamp: string | undefined, cutoffMs: number | null): boolean {
if (!timestamp || cutoffMs == null) { if (!timestamp || cutoffMs == null) {
return false; return false;
@ -324,6 +464,7 @@ export class TeamGraphAdapter {
leadId: string, leadId: string,
data: TeamData, data: TeamData,
teamName: string, teamName: string,
memberNodeIdByName: ReadonlyMap<string, string>,
spawnStatuses?: Record<string, MemberSpawnStatusEntry>, spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
pendingApprovalAgents?: Set<string>, pendingApprovalAgents?: Set<string>,
activeTools?: Record<string, Record<string, ActiveToolCall>>, activeTools?: Record<string, Record<string, ActiveToolCall>>,
@ -336,7 +477,8 @@ export class TeamGraphAdapter {
if (member.removedAt) continue; if (member.removedAt) continue;
if (isLeadMember(member)) 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 spawn = spawnStatuses?.[member.name];
const activeTool = TeamGraphAdapter.#selectVisibleTool( const activeTool = TeamGraphAdapter.#selectVisibleTool(
activeTools?.[member.name], activeTools?.[member.name],
@ -425,7 +567,8 @@ export class TeamGraphAdapter {
edges: GraphEdge[], edges: GraphEdge[],
data: TeamData, data: TeamData,
teamName: string, teamName: string,
commentReadState?: Record<string, unknown> commentReadState?: Record<string, unknown>,
memberNodeIdByName?: ReadonlyMap<string, string>
): void { ): void {
const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>(); const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>();
const taskDisplayIds = new Map<string, string>(); const taskDisplayIds = new Map<string, string>();
@ -446,7 +589,7 @@ export class TeamGraphAdapter {
for (const task of data.tasks) { for (const task of data.tasks) {
if (task.status === 'deleted') continue; if (task.status === 'deleted') continue;
const taskId = `task:${teamName}:${task.id}`; const taskId = `task:${teamName}:${task.id}`;
const ownerMemberId = task.owner ? `member:${teamName}:${task.owner}` : null; const ownerMemberId = task.owner ? (memberNodeIdByName?.get(task.owner) ?? null) : null;
const kanbanTaskState = data.kanbanState.tasks[task.id]; const kanbanTaskState = data.kanbanState.tasks[task.id];
const reviewerName = resolveTaskReviewer(task, kanbanTaskState); const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
const isReviewCycle = isTaskInReviewCycle(task); const isReviewCycle = isTaskInReviewCycle(task);
@ -496,7 +639,7 @@ export class TeamGraphAdapter {
} }
const { visibleNodes: visibleTaskNodes, visibleNodeIdByTaskId } = const { visibleNodes: visibleTaskNodes, visibleNodeIdByTaskId } =
collapseOverflowStacksWithMeta(rawTaskNodes, teamName, 6); collapseOverflowStacksWithMeta(rawTaskNodes, teamName, 5);
const visibleTaskIds = new Set( const visibleTaskIds = new Set(
visibleTaskNodes.flatMap((taskNode) => visibleTaskNodes.flatMap((taskNode) =>
taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : [] taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : []
@ -608,18 +751,21 @@ export class TeamGraphAdapter {
nodes: GraphNode[], nodes: GraphNode[],
edges: GraphEdge[], edges: GraphEdge[],
data: TeamData, data: TeamData,
teamName: string teamName: string,
memberNodeIdByName?: ReadonlyMap<string, string>
): void { ): void {
for (const proc of data.processes) { for (const { process: proc, ownerId } of TeamGraphAdapter.#selectRelevantProcesses(
if (proc.stoppedAt) continue; data.processes,
memberNodeIdByName
)) {
const procId = `process:${teamName}:${proc.id}`; const procId = `process:${teamName}:${proc.id}`;
const ownerId = proc.registeredBy ? `member:${teamName}:${proc.registeredBy}` : null;
nodes.push({ nodes.push({
id: procId, id: procId,
kind: 'process', kind: 'process',
label: proc.label, label: proc.label,
state: 'active', state: 'active',
ownerId,
processUrl: proc.url ?? undefined, processUrl: proc.url ?? undefined,
processRegisteredBy: proc.registeredBy ?? undefined, processRegisteredBy: proc.registeredBy ?? undefined,
processCommand: proc.command ?? undefined, processCommand: proc.command ?? undefined,
@ -638,6 +784,48 @@ export class TeamGraphAdapter {
} }
} }
static #selectRelevantProcesses(
processes: readonly TeamProcess[],
memberNodeIdByName?: ReadonlyMap<string, string>
): { process: TeamProcess; ownerId: string }[] {
const selectedByOwnerId = new Map<string, TeamProcess>();
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( #attachActivityFeeds(
nodes: GraphNode[], nodes: GraphNode[],
data: TeamData, data: TeamData,
@ -683,7 +871,8 @@ export class TeamGraphAdapter {
teamName: string, teamName: string,
leadId: string, leadId: string,
leadName: string, leadName: string,
edges: GraphEdge[] edges: GraphEdge[],
memberNodeIdByName: ReadonlyMap<string, string>
): void { ): void {
const ordered = [...messages].reverse(); const ordered = [...messages].reverse();
@ -766,16 +955,22 @@ export class TeamGraphAdapter {
continue; continue;
} }
const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, leadName, edges); const edgeId = TeamGraphAdapter.#resolveMessageEdge(
msg,
leadId,
leadName,
edges,
memberNodeIdByName
);
if (!edgeId) continue; if (!edgeId) continue;
// Determine direction: messages FROM a teammate TO lead should reverse // Determine direction: messages FROM a teammate TO lead should reverse
// (edges are always lead→member, but message goes member→lead) // (edges are always lead→member, but message goes member→lead)
const fromId = TeamGraphAdapter.#resolveParticipantId( const fromId = TeamGraphAdapter.#resolveParticipantId(
msg.from ?? '', msg.from ?? '',
teamName,
leadId, leadId,
leadName leadName,
memberNodeIdByName
); );
const isFromTeammate = fromId !== leadId; const isFromTeammate = fromId !== leadId;
@ -815,7 +1010,8 @@ export class TeamGraphAdapter {
teamName: string, teamName: string,
leadId: string, leadId: string,
leadName: string, leadName: string,
edges: GraphEdge[] edges: GraphEdge[],
memberNodeIdByName: ReadonlyMap<string, string>
): void { ): void {
// First call: record current comment counts without creating particles. // First call: record current comment counts without creating particles.
// This prevents pre-existing comments from spawning particles when the graph opens. // This prevents pre-existing comments from spawning particles when the graph opens.
@ -854,9 +1050,9 @@ export class TeamGraphAdapter {
} }
const authorNodeId = TeamGraphAdapter.#resolveParticipantId( const authorNodeId = TeamGraphAdapter.#resolveParticipantId(
newComment.author, newComment.author,
teamName,
leadId, leadId,
leadName leadName,
memberNodeIdByName
); );
const taskNodeId = `task:${teamName}:${task.id}`; const taskNodeId = `task:${teamName}:${task.id}`;
const authorEdge = const authorEdge =
@ -988,16 +1184,21 @@ export class TeamGraphAdapter {
static #resolveMessageEdge( static #resolveMessageEdge(
msg: InboxMessage, msg: InboxMessage,
teamName: string,
leadId: string, leadId: string,
leadName: string, leadName: string,
edges: GraphEdge[] edges: GraphEdge[],
memberNodeIdByName: ReadonlyMap<string, string>
): string | null { ): string | null {
const { from, to } = msg; const { from, to } = msg;
if (from && to) { if (from && to) {
const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId, leadName); const fromId = TeamGraphAdapter.#resolveParticipantId(
const toId = TeamGraphAdapter.#resolveParticipantId(to, teamName, leadId, leadName); from,
leadId,
leadName,
memberNodeIdByName
);
const toId = TeamGraphAdapter.#resolveParticipantId(to, leadId, leadName, memberNodeIdByName);
return ( return (
edges.find((e) => e.source === fromId && e.target === toId)?.id ?? edges.find((e) => e.source === fromId && e.target === toId)?.id ??
edges.find((e) => e.source === toId && e.target === fromId)?.id ?? edges.find((e) => e.source === toId && e.target === fromId)?.id ??
@ -1006,7 +1207,12 @@ export class TeamGraphAdapter {
} }
if (from && !to) { if (from && !to) {
const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId, leadName); const fromId = TeamGraphAdapter.#resolveParticipantId(
from,
leadId,
leadName,
memberNodeIdByName
);
return ( return (
edges.find( edges.find(
(e) => (e) =>
@ -1021,14 +1227,14 @@ export class TeamGraphAdapter {
static #resolveParticipantId( static #resolveParticipantId(
name: string, name: string,
teamName: string,
leadId: string, leadId: string,
leadName?: string leadName: string | undefined,
memberNodeIdByName: ReadonlyMap<string, string>
): string { ): string {
const normalized = name.trim().toLowerCase(); const normalized = name.trim().toLowerCase();
if (normalized === 'user' || normalized === 'team-lead') return leadId; if (normalized === 'user' || normalized === 'team-lead') return leadId;
if (normalized === leadName?.trim().toLowerCase()) return leadId; if (normalized === leadName?.trim().toLowerCase()) return leadId;
return `member:${teamName}:${name}`; return memberNodeIdByName.get(name) ?? leadId;
} }
/** Extract external team name from cross-team "from" field like "team-b.alice" */ /** Extract external team name from cross-team "from" field like "team-b.alice" */

View file

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

View file

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

View file

@ -3,7 +3,7 @@
* Thin wrapper instantiates the class adapter and calls adapt() with store data. * 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 { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
@ -31,6 +31,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
toolHistory, toolHistory,
provisioningProgress, provisioningProgress,
memberSpawnSnapshot, memberSpawnSnapshot,
slotAssignments,
ensureTeamGraphSlotAssignments,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
teamData: selectTeamDataForName(s, teamName), teamData: selectTeamDataForName(s, teamName),
@ -43,6 +45,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
toolHistory: teamName ? s.toolHistoryByTeam[teamName] : undefined, toolHistory: teamName ? s.toolHistoryByTeam[teamName] : undefined,
provisioningProgress: teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null, provisioningProgress: teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null,
memberSpawnSnapshot: teamName ? s.memberSpawnSnapshotsByTeam[teamName] : undefined, 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); const commentReadState = useSyncExternalStore(subscribe, getSnapshot);
useEffect(() => {
if (!teamName || !teamData) {
return;
}
ensureTeamGraphSlotAssignments(teamName, teamData.members);
}, [ensureTeamGraphSlotAssignments, teamData, teamName]);
return useMemo( return useMemo(
() => () =>
adapterRef.current.adapt( adapterRef.current.adapt(
@ -72,7 +83,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
toolHistory, toolHistory,
commentReadState, commentReadState,
provisioningProgress, provisioningProgress,
memberSpawnSnapshot memberSpawnSnapshot,
slotAssignments
), ),
[ [
teamData, teamData,
@ -87,6 +99,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
commentReadState, commentReadState,
provisioningProgress, provisioningProgress,
memberSpawnSnapshot, memberSpawnSnapshot,
slotAssignments,
] ]
); );
} }

View file

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

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from '@claude-teams/agent-graph';
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
import { import {
buildMessageContext, buildMessageContext,
@ -8,16 +9,14 @@ import {
import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog'; import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog';
import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta'; import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; 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 { toMessageKey } from '@renderer/utils/teamMessageKey';
import { useShallow } from 'zustand/react/shallow';
import { import {
buildInlineActivityEntries, buildInlineActivityEntries,
getGraphLeadMemberName, getGraphLeadMemberName,
type InlineActivityEntry, type InlineActivityEntry,
} from '../../core/domain/buildInlineActivityEntries'; } from '../../core/domain/buildInlineActivityEntries';
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
import type { GraphNode } from '@claude-teams/agent-graph'; import type { GraphNode } from '@claude-teams/agent-graph';
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup'; import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
@ -66,18 +65,12 @@ export const GraphActivityHud = ({
onOpenTaskDetail, onOpenTaskDetail,
onOpenMemberProfile, onOpenMemberProfile,
}: GraphActivityHudProps): React.JSX.Element | null => { }: GraphActivityHudProps): React.JSX.Element | null => {
const ACTIVITY_LANE_WIDTH = 296;
const worldLayerRef = useRef<HTMLDivElement | null>(null); const worldLayerRef = useRef<HTMLDivElement | null>(null);
const shellRefs = useRef(new Map<string, HTMLDivElement | null>()); const shellRefs = useRef(new Map<string, HTMLDivElement | null>());
const connectorRefs = useRef(new Map<string, SVGSVGElement | null>()); const connectorRefs = useRef(new Map<string, SVGSVGElement | null>());
const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>()); const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>());
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null); const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
const { teamData, teams } = useStore( const { teamData, teams } = useGraphActivityContext(teamName);
useShallow((state) => ({
teamData: selectTeamDataForName(state, teamName),
teams: state.teams,
}))
);
const ownerNodes = useMemo( 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)})`; 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]; lane: (typeof visibleLanes)[number];
shell: HTMLDivElement; shell: HTMLDivElement;
connector: SVGSVGElement | null; connector: SVGSVGElement | null;
connectorPath: SVGPathElement | null; connectorPath: SVGPathElement | null;
laneTopLeft: { x: number; y: number }; laneTopLeft: { x: number; y: number };
nodeWorld: { x: number; y: number }; nodeWorld: { x: number; y: number };
scale: number; }[] = [];
}> = [];
for (const lane of visibleLanes) { for (const lane of visibleLanes) {
const shell = shellRefs.current.get(lane.node.id); const shell = shellRefs.current.get(lane.node.id);
@ -189,7 +181,7 @@ export const GraphActivityHud = ({
} }
const scale = Math.max(getCameraZoom(), 0.001); 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 heightScreen = Math.max(1, (shell.offsetHeight || 220) * scale);
const viewport = getViewportSize?.(); const viewport = getViewportSize?.();
const laneVisible = viewport const laneVisible = viewport
@ -215,26 +207,31 @@ export const GraphActivityHud = ({
connectorPath, connectorPath,
laneTopLeft, laneTopLeft,
nodeWorld, nodeWorld,
scale,
}); });
} }
for (const entry of measurableLanes) { 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 baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1;
const widthWorld = shell.offsetWidth || ACTIVITY_LANE.width;
const heightWorld = shell.offsetHeight || 220;
const ownerBottomLimit =
nodeWorld.y +
(lane.node.kind === 'lead'
? ACTIVITY_ANCHOR_LAYOUT.leadOffsetY + ACTIVITY_ANCHOR_LAYOUT.reservedHeight
: ACTIVITY_ANCHOR_LAYOUT.memberOffsetY + ACTIVITY_ANCHOR_LAYOUT.reservedHeight);
const adjustedLaneTop = Math.min(laneTopLeft.y, ownerBottomLimit - heightWorld);
shell.style.opacity = String(baseOpacity); shell.style.opacity = String(baseOpacity);
shell.style.left = `${Math.round(laneTopLeft.x)}px`; shell.style.left = `${Math.round(laneTopLeft.x)}px`;
shell.style.top = `${Math.round(laneTopLeft.y)}px`; shell.style.top = `${Math.round(adjustedLaneTop)}px`;
shell.style.transform = ''; shell.style.transform = '';
if (connector && connectorPath) { if (connector && connectorPath) {
const widthWorld = shell.offsetWidth || ACTIVITY_LANE_WIDTH; const endX = laneTopLeft.x + widthWorld / 2;
const laneCenterX = laneTopLeft.x + widthWorld / 2; const endY = adjustedLaneTop + heightWorld - 6;
const laneIsLeft = laneCenterX < nodeWorld.x;
const endX = laneIsLeft ? laneTopLeft.x + widthWorld - 8 : laneTopLeft.x + 8;
const endY = laneTopLeft.y + 10;
const startX = nodeWorld.x; const startX = nodeWorld.x;
const startY = nodeWorld.y - 10 / scale; const startY = nodeWorld.y - 18;
const minX = Math.min(startX, endX); const minX = Math.min(startX, endX);
const minY = Math.min(startY, endY); const minY = Math.min(startY, endY);
const connectorWidth = Math.max(1, Math.abs(endX - startX)); const connectorWidth = Math.max(1, Math.abs(endX - startX));
@ -367,14 +364,14 @@ export const GraphActivityHud = ({
return; return;
} }
const listeners: Array<{ shell: HTMLDivElement; handler: (event: WheelEvent) => void }> = []; const listeners: { shell: HTMLDivElement; handler: (event: WheelEvent) => void }[] = [];
for (const lane of visibleLanes) { for (const lane of visibleLanes) {
const shell = shellRefs.current.get(lane.node.id); const shell = shellRefs.current.get(lane.node.id);
if (!shell) { if (!shell) {
continue; continue;
} }
const handler = (event: WheelEvent) => forwardWheelToGraph(event, shell); const handler = (event: WheelEvent): void => forwardWheelToGraph(event, shell);
shell.addEventListener('wheel', handler, { passive: false }); shell.addEventListener('wheel', handler, { passive: false });
listeners.push({ shell, handler }); listeners.push({ shell, handler });
} }
@ -421,7 +418,7 @@ export const GraphActivityHud = ({
shellRefs.current.set(lane.node.id, element); shellRefs.current.set(lane.node.id, element);
}} }}
className="pointer-events-auto absolute z-10 origin-top-left opacity-0" 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` }}
> >
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70"> <div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
Activity Activity

View file

@ -7,10 +7,11 @@ import { useCallback, useMemo } from 'react';
import { GraphView } from '@claude-teams/agent-graph'; import { GraphView } from '@claude-teams/agent-graph';
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
import { useStore } from '@renderer/store';
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
import { GraphActivityHud } from './GraphActivityHud'; import { GraphActivityHud } from './GraphActivityHud';
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover'; import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
@ -27,6 +28,8 @@ export interface TeamGraphOverlayProps {
teamName: string; teamName: string;
onClose: () => void; onClose: () => void;
onPinAsTab?: () => void; onPinAsTab?: () => void;
sidebarVisible?: boolean;
onToggleSidebar?: () => void;
onSendMessage?: (memberName: string) => void; onSendMessage?: (memberName: string) => void;
onOpenTaskDetail?: (taskId: string) => void; onOpenTaskDetail?: (taskId: string) => void;
onOpenMemberProfile?: ( onOpenMemberProfile?: (
@ -42,12 +45,19 @@ export const TeamGraphOverlay = ({
teamName, teamName,
onClose, onClose,
onPinAsTab, onPinAsTab,
sidebarVisible,
onToggleSidebar,
onSendMessage, onSendMessage,
onOpenTaskDetail, onOpenTaskDetail,
onOpenMemberProfile, onOpenMemberProfile,
}: TeamGraphOverlayProps): React.JSX.Element => { }: TeamGraphOverlayProps): React.JSX.Element => {
const graphData = useTeamGraphAdapter(teamName); const graphData = useTeamGraphAdapter(teamName);
const { openTeamPage: openTeamTab, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
useGraphSidebarVisibility();
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible;
const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible;
const leadNodeId = useMemo( const leadNodeId = useMemo(
() => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null, () => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null,
[graphData.nodes] [graphData.nodes]
@ -73,9 +83,9 @@ export const TeamGraphOverlay = ({
[dispatchTaskAction] [dispatchTaskAction]
); );
const openTeamPage = useCallback(() => { const openTeamPage = useCallback(() => {
useStore.getState().openTeamTab(teamName); openTeamTab();
onClose(); onClose();
}, [onClose, teamName]); }, [onClose, openTeamTab]);
const openCreateTask = useCallback(() => { const openCreateTask = useCallback(() => {
openCreateTaskDialog(''); openCreateTaskDialog('');
}, [openCreateTaskDialog]); }, [openCreateTaskDialog]);
@ -104,7 +114,9 @@ export const TeamGraphOverlay = ({
return ( return (
<div className="fixed inset-0 z-50 flex overflow-hidden" style={{ background: '#050510' }}> <div className="fixed inset-0 z-50 flex overflow-hidden" style={{ background: '#050510' }}>
<TeamSidebarHost teamName={teamName} surface="graph-overlay" isActive isFocused /> {effectiveSidebarVisible ? (
<TeamSidebarHost teamName={teamName} surface="graph-overlay" isActive isFocused />
) : null}
<GraphView <GraphView
data={graphData} data={graphData}
events={events} events={events}
@ -112,6 +124,9 @@ export const TeamGraphOverlay = ({
onRequestPinAsTab={onPinAsTab} onRequestPinAsTab={onPinAsTab}
onOpenTeamPage={openTeamPage} onOpenTeamPage={openTeamPage}
onCreateTask={openCreateTask} onCreateTask={openCreateTask}
onToggleSidebar={handleToggleSidebar}
isSidebarVisible={effectiveSidebarVisible}
onOwnerSlotDrop={commitOwnerSlotDrop}
className="team-graph-view min-w-0 flex-1" className="team-graph-view min-w-0 flex-1"
renderHud={(hudProps) => { renderHud={(hudProps) => {
const extraHudProps = hudProps as typeof hudProps & { const extraHudProps = hudProps as typeof hudProps & {

View file

@ -7,10 +7,11 @@ import { lazy, Suspense, useCallback, useMemo, useState } from 'react';
import { GraphView } from '@claude-teams/agent-graph'; import { GraphView } from '@claude-teams/agent-graph';
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
import { useStore } from '@renderer/store';
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
import { GraphActivityHud } from './GraphActivityHud'; import { GraphActivityHud } from './GraphActivityHud';
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover'; import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
@ -44,11 +45,13 @@ export const TeamGraphTab = ({
isPaneFocused = false, isPaneFocused = false,
}: TeamGraphTabProps): React.JSX.Element => { }: TeamGraphTabProps): React.JSX.Element => {
const graphData = useTeamGraphAdapter(teamName); const graphData = useTeamGraphAdapter(teamName);
const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
const leadNodeId = useMemo( const leadNodeId = useMemo(
() => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null, () => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null,
[graphData.nodes] [graphData.nodes]
); );
const [fullscreen, setFullscreen] = useState(false); const [fullscreen, setFullscreen] = useState(false);
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
// Typed event dispatchers (DRY — used in both events + renderOverlay) // Typed event dispatchers (DRY — used in both events + renderOverlay)
@ -73,9 +76,6 @@ export const TeamGraphTab = ({
), ),
[teamName] [teamName]
); );
const openTeamPage = useCallback(() => {
useStore.getState().openTeamTab(teamName);
}, [teamName]);
const openCreateTask = useCallback(() => { const openCreateTask = useCallback(() => {
openCreateTaskDialog(''); openCreateTaskDialog('');
}, [openCreateTaskDialog]); }, [openCreateTaskDialog]);
@ -130,12 +130,14 @@ export const TeamGraphTab = ({
return ( return (
<div className="flex size-full overflow-hidden" style={{ background: '#050510' }}> <div className="flex size-full overflow-hidden" style={{ background: '#050510' }}>
<TeamSidebarHost {sidebarVisible ? (
teamName={teamName} <TeamSidebarHost
surface="graph-tab" teamName={teamName}
isActive={isActive} surface="graph-tab"
isFocused={isPaneFocused} isActive={isActive}
/> isFocused={isPaneFocused}
/>
) : null}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<GraphView <GraphView
data={graphData} data={graphData}
@ -145,6 +147,9 @@ export const TeamGraphTab = ({
onRequestFullscreen={() => setFullscreen(true)} onRequestFullscreen={() => setFullscreen(true)}
onOpenTeamPage={openTeamPage} onOpenTeamPage={openTeamPage}
onCreateTask={openCreateTask} onCreateTask={openCreateTask}
onToggleSidebar={toggleSidebarVisible}
isSidebarVisible={sidebarVisible}
onOwnerSlotDrop={commitOwnerSlotDrop}
renderHud={(hudProps) => { renderHud={(hudProps) => {
const extraHudProps = hudProps as typeof hudProps & { const extraHudProps = hudProps as typeof hudProps & {
getViewportSize?: () => { width: number; height: number }; getViewportSize?: () => { width: number; height: number };
@ -227,6 +232,8 @@ export const TeamGraphTab = ({
<TeamGraphOverlay <TeamGraphOverlay
teamName={teamName} teamName={teamName}
onClose={() => setFullscreen(false)} onClose={() => setFullscreen(false)}
sidebarVisible={sidebarVisible}
onToggleSidebar={toggleSidebarVisible}
onSendMessage={dispatchSendMessage} onSendMessage={dispatchSendMessage}
onOpenTaskDetail={dispatchOpenTask} onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile} onOpenMemberProfile={dispatchOpenProfile}

View file

@ -168,16 +168,18 @@ describe('TmuxInstallerRunnerAdapter', () => {
retryWithUpdateCommand: null, retryWithUpdateCommand: null,
})), })),
}; };
let resolveTerminalRun: ((result: { exitCode: number }) => void) | null = null; const resolveTerminalRunRef: { current: ((result: { exitCode: number }) => void) | null } = {
current: null,
};
const terminalSession = { const terminalSession = {
run: vi.fn( run: vi.fn(
() => () =>
new Promise<{ exitCode: number }>((resolve) => { new Promise<{ exitCode: number }>((resolve) => {
resolveTerminalRun = resolve; resolveTerminalRunRef.current = resolve;
}) })
), ),
writeLine: vi.fn((input: string) => { writeLine: vi.fn((input: string) => {
resolveTerminalRun?.({ exitCode: 0 }); resolveTerminalRunRef.current?.({ exitCode: 0 });
return input; return input;
}), }),
cancel: vi.fn(), cancel: vi.fn(),
@ -208,12 +210,14 @@ describe('TmuxInstallerRunnerAdapter', () => {
getStatus: vi.fn(async () => createBaseStatus()), getStatus: vi.fn(async () => createBaseStatus()),
invalidateStatus: vi.fn(), invalidateStatus: vi.fn(),
}; };
let resolveCommandRun: ((result: { exitCode: number }) => void) | null = null; const resolveCommandRunRef: { current: ((result: { exitCode: number }) => void) | null } = {
current: null,
};
const commandRunner = { const commandRunner = {
run: vi.fn( run: vi.fn(
() => () =>
new Promise<{ exitCode: number }>((resolve) => { new Promise<{ exitCode: number }>((resolve) => {
resolveCommandRun = resolve; resolveCommandRunRef.current = resolve;
}) })
), ),
cancel: vi.fn(), cancel: vi.fn(),
@ -244,7 +248,7 @@ describe('TmuxInstallerRunnerAdapter', () => {
(snapshot) => snapshot.canCancel (snapshot) => snapshot.canCancel
); );
await runner.cancel(); await runner.cancel();
resolveCommandRun?.({ exitCode: 1 }); resolveCommandRunRef.current?.({ exitCode: 1 });
await expect(installPromise).resolves.toBeUndefined(); await expect(installPromise).resolves.toBeUndefined();

View file

@ -44,27 +44,30 @@ describe('TmuxStatusSourceAdapter', () => {
it('does not reuse or recache a stale in-flight probe after invalidateStatus()', async () => { it('does not reuse or recache a stale in-flight probe after invalidateStatus()', async () => {
const childProcess = await import('node:child_process'); const childProcess = await import('node:child_process');
let firstCallback: type ExecFileCallback = (
| ((error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void) error: Error | null,
| null = null; stdout: string | Buffer,
stderr: string | Buffer
) => void;
const firstCallbackRef: { current: ExecFileCallback | null } = {
current: null,
};
const execFileMock = vi.mocked(childProcess.execFile); const execFileMock = vi.mocked(childProcess.execFile);
execFileMock.mockImplementation( execFileMock.mockImplementation(((
( _command: string,
_command: string, _args: readonly string[] | null | undefined,
_args: string[], _options: unknown,
_options: unknown, callback: ExecFileCallback
callback: (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void ) => {
) => { if (!firstCallbackRef.current) {
if (!firstCallback) { firstCallbackRef.current = callback;
firstCallback = callback;
return {} as never;
}
callback(null, 'tmux second\n', '');
return {} as never; return {} as never;
} }
);
callback(null, 'tmux second\n', '');
return {} as never;
}) as never);
const adapter = new TmuxStatusSourceAdapter( const adapter = new TmuxStatusSourceAdapter(
{ {
@ -92,7 +95,7 @@ describe('TmuxStatusSourceAdapter', () => {
expect(secondStatus.host.version).toBe('tmux second'); expect(secondStatus.host.version).toBe('tmux second');
firstCallback?.(null, 'tmux first\n', ''); firstCallbackRef.current?.(null, 'tmux first\n', '');
await firstStatusPromise; await firstStatusPromise;
await Promise.resolve(); await Promise.resolve();

View file

@ -40,7 +40,7 @@ export class TmuxCommandRunner {
flush: () => void; flush: () => void;
} => { } => {
let pending = ''; let pending = '';
let pendingBytes = Buffer.alloc(0); let pendingBytes: Buffer<ArrayBufferLike> = Buffer.alloc(0);
const emitLine = (line: string): void => { const emitLine = (line: string): void => {
const normalizedLine = line.replace(/\r$/, ''); const normalizedLine = line.replace(/\r$/, '');

View file

@ -76,6 +76,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
const manualHintsVisible = const manualHintsVisible =
viewModel.manualHints.length > 0 && (!viewModel.manualHintsCollapsible || manualHintsExpanded); viewModel.manualHints.length > 0 && (!viewModel.manualHintsCollapsible || manualHintsExpanded);
const primaryGuideUrl = viewModel.primaryGuideUrl;
return ( return (
<div <div
@ -249,10 +250,10 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
Cancel Cancel
</button> </button>
)} )}
{viewModel.primaryGuideUrl && ( {primaryGuideUrl && (
<button <button
type="button" type="button"
onClick={() => void openExternal(viewModel.primaryGuideUrl)} onClick={() => void openExternal(primaryGuideUrl)}
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5" className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)' }} style={{ borderColor: 'var(--color-border)' }}
> >

View file

@ -22,8 +22,8 @@ import { stripMarkdown } from '@main/utils/textFormatting';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { Notification as ElectronNotification } from 'electron';
import * as fsp from 'fs/promises'; import * as fsp from 'fs/promises';
import { createRequire } from 'module';
import * as path from 'path'; import * as path from 'path';
import { type DetectedError } from '../error/ErrorMessageBuilder'; import { type DetectedError } from '../error/ErrorMessageBuilder';
@ -31,7 +31,6 @@ import { type DetectedError } from '../error/ErrorMessageBuilder';
import type { BrowserWindow, NotificationConstructorOptions } from 'electron'; import type { BrowserWindow, NotificationConstructorOptions } from 'electron';
const logger = createLogger('Service:NotificationManager'); const logger = createLogger('Service:NotificationManager');
const require = createRequire(import.meta.url);
import { import {
buildDetectedErrorFromTeam, buildDetectedErrorFromTeam,
type TeamNotificationPayload, type TeamNotificationPayload,
@ -108,21 +107,8 @@ interface NotificationClass {
isSupported(): boolean; isSupported(): boolean;
} }
let cachedNotificationClass: NotificationClass | null | undefined;
function getNotificationClass(): NotificationClass | null { function getNotificationClass(): NotificationClass | null {
if (cachedNotificationClass !== undefined) { return (ElectronNotification as NotificationClass | undefined) ?? null;
return cachedNotificationClass;
}
try {
const electronModule = require('electron') as { Notification?: NotificationClass };
cachedNotificationClass = electronModule.Notification ?? null;
} catch {
cachedNotificationClass = null;
}
return cachedNotificationClass;
} }
// ============================================================================= // =============================================================================
@ -410,14 +396,14 @@ export class NotificationManager extends EventEmitter {
* Closes over `stored` (StoredNotification) so click handler has full data. * Closes over `stored` (StoredNotification) so click handler has full data.
*/ */
private showErrorNativeNotification(stored: StoredNotification): void { private showErrorNativeNotification(stored: StoredNotification): void {
const Notification = getNotificationClass(); const NotificationClass = getNotificationClass();
if (!Notification || !this.isNativeNotificationSupported()) return; if (!NotificationClass || !this.isNativeNotificationSupported()) return;
const config = this.configManager.getConfig(); const config = this.configManager.getConfig();
const isMac = process.platform === 'darwin'; const isMac = process.platform === 'darwin';
const truncatedMessage = stripMarkdown(stored.message).slice(0, 200); const truncatedMessage = stripMarkdown(stored.message).slice(0, 200);
const iconPath = isMac ? undefined : getAppIconPath(); const iconPath = isMac ? undefined : getAppIconPath();
const notification = new Notification({ const notification = new NotificationClass({
title: 'Claude Code Error', title: 'Claude Code Error',
...(isMac ? { subtitle: stored.context.projectName } : {}), ...(isMac ? { subtitle: stored.context.projectName } : {}),
body: isMac ? truncatedMessage : `${stored.context.projectName}\n${truncatedMessage}`, body: isMac ? truncatedMessage : `${stored.context.projectName}\n${truncatedMessage}`,
@ -456,8 +442,8 @@ export class NotificationManager extends EventEmitter {
stored: StoredNotification, stored: StoredNotification,
payload: TeamNotificationPayload payload: TeamNotificationPayload
): void { ): void {
const Notification = getNotificationClass(); const NotificationClass = getNotificationClass();
if (!Notification || !this.isNativeNotificationSupported()) { if (!NotificationClass || !this.isNativeNotificationSupported()) {
logger.warn('[team-toast] native notifications not supported — skipping'); logger.warn('[team-toast] native notifications not supported — skipping');
return; return;
} }
@ -472,7 +458,7 @@ export class NotificationManager extends EventEmitter {
`[team-toast] creating: title="${payload.teamDisplayName}" summary="${payload.summary ?? ''}" bodyLen=${truncatedBody.length}` `[team-toast] creating: title="${payload.teamDisplayName}" summary="${payload.summary ?? ''}" bodyLen=${truncatedBody.length}`
); );
const notification = new Notification({ const notification = new NotificationClass({
title: payload.teamDisplayName, title: payload.teamDisplayName,
...(isMac ? { subtitle: payload.summary } : {}), ...(isMac ? { subtitle: payload.summary } : {}),
body: !isMac && payload.summary ? `${payload.summary}\n${truncatedBody}` : truncatedBody, body: !isMac && payload.summary ? `${payload.summary}\n${truncatedBody}` : truncatedBody,
@ -546,8 +532,8 @@ export class NotificationManager extends EventEmitter {
* Returns a result object indicating success or failure reason. * Returns a result object indicating success or failure reason.
*/ */
sendTestNotification(): { success: boolean; error?: string } { sendTestNotification(): { success: boolean; error?: string } {
const Notification = getNotificationClass(); const NotificationClass = getNotificationClass();
if (!this.isNativeNotificationSupported()) { if (!NotificationClass || !this.isNativeNotificationSupported()) {
logger.warn('[test-notification] native notifications not supported'); logger.warn('[test-notification] native notifications not supported');
return { success: false, error: 'Native notifications are not supported on this platform' }; return { success: false, error: 'Native notifications are not supported on this platform' };
} }
@ -555,7 +541,7 @@ export class NotificationManager extends EventEmitter {
const isMac = process.platform === 'darwin'; const isMac = process.platform === 'darwin';
const iconPath = isMac ? undefined : getAppIconPath(); const iconPath = isMac ? undefined : getAppIconPath();
logger.debug(`[test-notification] creating Notification (platform=${process.platform})`); logger.debug(`[test-notification] creating Notification (platform=${process.platform})`);
const notification = new Notification({ const notification = new NotificationClass({
title: 'Test Notification', title: 'Test Notification',
...(isMac ? { subtitle: 'Claude Agent Teams UI' } : {}), ...(isMac ? { subtitle: 'Claude Agent Teams UI' } : {}),
body: isMac body: isMac

View file

@ -2,6 +2,7 @@ import {
createCliAutoSuffixNameGuard, createCliAutoSuffixNameGuard,
createCliProvisionerNameGuard, createCliProvisionerNameGuard,
} from '@shared/utils/teamMemberName'; } from '@shared/utils/teamMemberName';
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
import type { import type {
InboxMessage, InboxMessage,
@ -122,6 +123,7 @@ export class TeamMemberResolver {
const configMemberMap = new Map< const configMemberMap = new Map<
string, string,
{ {
agentId?: string;
agentType?: string; agentType?: string;
role?: string; role?: string;
workflow?: string; workflow?: string;
@ -147,6 +149,7 @@ export class TeamMemberResolver {
? configMember.provider ? configMember.provider
: undefined; : undefined;
configMemberMap.set(m.name.trim(), { configMemberMap.set(m.name.trim(), {
agentId: configMember.agentId,
agentType: configMember.agentType, agentType: configMember.agentType,
role: configMember.role, role: configMember.role,
workflow: configMember.workflow, workflow: configMember.workflow,
@ -163,6 +166,7 @@ export class TeamMemberResolver {
const metaMemberMap = new Map< const metaMemberMap = new Map<
string, string,
{ {
agentId?: string;
agentType?: string; agentType?: string;
role?: string; role?: string;
workflow?: string; workflow?: string;
@ -177,6 +181,7 @@ export class TeamMemberResolver {
for (const member of metaMembers) { for (const member of metaMembers) {
if (typeof member?.name === 'string' && member.name.trim() !== '') { if (typeof member?.name === 'string' && member.name.trim() !== '') {
metaMemberMap.set(member.name.trim(), { metaMemberMap.set(member.name.trim(), {
agentId: member.agentId,
agentType: member.agentType, agentType: member.agentType,
role: member.role, role: member.role,
workflow: member.workflow, workflow: member.workflow,
@ -226,8 +231,10 @@ export class TeamMemberResolver {
const status = this.resolveStatus(latestMessage, currentTask !== null); const status = this.resolveStatus(latestMessage, currentTask !== null);
const configMember = configMemberMap.get(name); const configMember = configMemberMap.get(name);
const metaMember = metaMemberMap.get(name); const metaMember = metaMemberMap.get(name);
const agentId = configMember?.agentId ?? metaMember?.agentId;
members.push({ members.push({
name, name,
agentId,
status, status,
currentTaskId: currentTask?.id ?? null, currentTaskId: currentTask?.id ?? null,
taskCount: ownedTasks.length, taskCount: ownedTasks.length,
@ -245,7 +252,29 @@ export class TeamMemberResolver {
}); });
} }
members.sort((a, b) => a.name.localeCompare(b.name)); const explicitConfigOrder = new Map<string, number>();
for (const [index, member] of config.members?.entries() ?? []) {
const stableOwnerId = getStableTeamOwnerId(member);
explicitConfigOrder.set(stableOwnerId, index);
explicitConfigOrder.set(member.name, index);
}
members.sort((a, b) => {
const aStableId = getStableTeamOwnerId(a);
const bStableId = getStableTeamOwnerId(b);
const aConfigIndex =
explicitConfigOrder.get(aStableId) ??
explicitConfigOrder.get(a.name) ??
Number.POSITIVE_INFINITY;
const bConfigIndex =
explicitConfigOrder.get(bStableId) ??
explicitConfigOrder.get(b.name) ??
Number.POSITIVE_INFINITY;
if (aConfigIndex !== bConfigIndex) {
return aConfigIndex - bConfigIndex;
}
return aStableId.localeCompare(bStableId);
});
return members; return members;
} }

View file

@ -21,6 +21,7 @@ import type {
ConfigAPI, ConfigAPI,
ContextInfo, ContextInfo,
ConversationGroup, ConversationGroup,
CreateScheduleInput,
CreateTaskRequest, CreateTaskRequest,
CrossTeamAPI, CrossTeamAPI,
ElectronAPI, ElectronAPI,
@ -69,6 +70,7 @@ import type {
TmuxAPI, TmuxAPI,
TmuxStatus, TmuxStatus,
TriggerTestResult, TriggerTestResult,
UpdateSchedulePatch,
UpdateKanbanPatch, UpdateKanbanPatch,
UpdaterAPI, UpdaterAPI,
WaterfallData, WaterfallData,

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { buildGraphMemberNodeIdForMember } from '@features/agent-graph/core/domain/graphOwnerIdentity';
import { buildInlineActivityEntries } from '@features/agent-graph/renderer'; import { buildInlineActivityEntries } from '@features/agent-graph/renderer';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
@ -88,7 +89,8 @@ export const MemberDetailDialog = ({
const leadId = `lead:${teamName}`; const leadId = `lead:${teamName}`;
const leadName = const leadName =
members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`; members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`;
const ownerNodeId = member.name === leadName ? leadId : `member:${teamName}:${member.name}`; const ownerNodeId =
member.name === leadName ? leadId : buildGraphMemberNodeIdForMember(teamName, member);
const entries = buildInlineActivityEntries({ const entries = buildInlineActivityEntries({
data: { data: {
members, members,

View file

@ -13,10 +13,12 @@ import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
import type { AppState } from '../types'; import type { AppState } from '../types';
import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
import type { AppConfig } from '@renderer/types/data'; import type { AppConfig } from '@renderer/types/data';
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
import type { import type {
@ -52,6 +54,7 @@ import type {
} from '@shared/types'; } from '@shared/types';
import type { StateCreator } from 'zustand'; import type { StateCreator } from 'zustand';
const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const;
const logger = createLogger('teamSlice'); const logger = createLogger('teamSlice');
const TEAM_GET_DATA_TIMEOUT_MS = 30_000; const TEAM_GET_DATA_TIMEOUT_MS = 30_000;
@ -82,6 +85,8 @@ interface RefreshTeamDataOptions {
withDedup?: boolean; withDedup?: boolean;
} }
type TeamGraphSlotAssignments = Record<string, GraphOwnerSlotAssignment>;
export function isTeamDataRefreshPending(teamName: string): boolean { export function isTeamDataRefreshPending(teamName: string): boolean {
return ( return (
inFlightTeamDataRequests.has(teamName) || inFlightTeamDataRequests.has(teamName) ||
@ -936,6 +941,35 @@ export function selectTeamDataForName(
); );
} }
function migrateStableSlotAssignmentsForMembers(
assignments: TeamGraphSlotAssignments | undefined,
members: readonly Pick<TeamData['members'][number], 'name' | 'agentId'>[]
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) };
let changed = false;
for (const member of members) {
const fallbackKey = member.name.trim();
const stableOwnerId = getStableTeamOwnerId(member);
const fallbackAssignment = nextAssignments[fallbackKey];
const stableAssignment = nextAssignments[stableOwnerId];
if (stableOwnerId !== fallbackKey && fallbackAssignment && !stableAssignment) {
nextAssignments[stableOwnerId] = fallbackAssignment;
delete nextAssignments[fallbackKey];
changed = true;
continue;
}
if (stableOwnerId !== fallbackKey && fallbackAssignment && stableAssignment) {
delete nextAssignments[fallbackKey];
changed = true;
}
}
return { assignments: nextAssignments, changed };
}
function isVisibleInActiveTeamSurface( function isVisibleInActiveTeamSurface(
state: Pick<AppState, 'paneLayout'>, state: Pick<AppState, 'paneLayout'>,
teamName: string | null | undefined teamName: string | null | undefined
@ -997,6 +1031,8 @@ export interface TeamSlice {
selectedTeamData: TeamData | null; selectedTeamData: TeamData | null;
/** Team-scoped detailed cache used by multi-pane views like agent graph. */ /** Team-scoped detailed cache used by multi-pane views like agent graph. */
teamDataCacheByName: Record<string, TeamData>; teamDataCacheByName: Record<string, TeamData>;
slotLayoutVersion: string;
slotAssignmentsByTeam: Record<string, TeamGraphSlotAssignments>;
selectedTeamLoading: boolean; selectedTeamLoading: boolean;
selectedTeamLoadNonce: number; selectedTeamLoadNonce: number;
selectedTeamError: string | null; selectedTeamError: string | null;
@ -1039,6 +1075,29 @@ export interface TeamSlice {
openTeamsTab: () => void; openTeamsTab: () => void;
openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => void; openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => void;
clearKanbanFilter: () => void; clearKanbanFilter: () => void;
ensureTeamGraphSlotAssignments: (
teamName: string,
members: readonly Pick<TeamData['members'][number], 'name' | 'agentId'>[]
) => void;
setTeamGraphOwnerSlotAssignment: (
teamName: string,
stableOwnerId: string,
assignment: GraphOwnerSlotAssignment
) => void;
commitTeamGraphOwnerSlotDrop: (
teamName: string,
stableOwnerId: string,
assignment: GraphOwnerSlotAssignment,
displacedStableOwnerId?: string,
displacedAssignment?: GraphOwnerSlotAssignment
) => void;
swapTeamGraphOwnerSlots: (
teamName: string,
stableOwnerId: string,
otherStableOwnerId: string
) => void;
clearTeamGraphSlotAssignments: (teamName?: string) => void;
resetTeamGraphSlotAssignmentsToDefaults: (teamName: string) => void;
setSelectedTeamTaskChangePresence: ( setSelectedTeamTaskChangePresence: (
teamName: string, teamName: string,
taskId: string, taskId: string,
@ -1289,6 +1348,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
selectedTeamName: null, selectedTeamName: null,
selectedTeamData: null, selectedTeamData: null,
teamDataCacheByName: {}, teamDataCacheByName: {},
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
slotAssignmentsByTeam: {},
selectedTeamLoading: false, selectedTeamLoading: false,
selectedTeamLoadNonce: 0, selectedTeamLoadNonce: 0,
selectedTeamError: null, selectedTeamError: null,
@ -1708,6 +1769,205 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
set({ kanbanFilterQuery: null }); set({ kanbanFilterQuery: null });
}, },
ensureTeamGraphSlotAssignments: (teamName, members) => {
set((state) => {
const nextState: Partial<TeamSlice> = {};
let changed = false;
let nextSlotAssignmentsByTeam = state.slotAssignmentsByTeam;
if (state.slotLayoutVersion !== GRAPH_STABLE_SLOT_LAYOUT_VERSION) {
nextState.slotLayoutVersion = GRAPH_STABLE_SLOT_LAYOUT_VERSION;
nextSlotAssignmentsByTeam = {};
changed = true;
}
const currentAssignments = nextSlotAssignmentsByTeam[teamName];
const migrated = migrateStableSlotAssignmentsForMembers(currentAssignments, members);
if (migrated.changed) {
nextSlotAssignmentsByTeam = {
...nextSlotAssignmentsByTeam,
[teamName]: migrated.assignments,
};
changed = true;
}
if (!changed) {
return {};
}
nextState.slotAssignmentsByTeam = nextSlotAssignmentsByTeam;
return nextState;
});
},
setTeamGraphOwnerSlotAssignment: (teamName, stableOwnerId, assignment) => {
set((state) => {
const currentAssignments = state.slotAssignmentsByTeam[teamName] ?? {};
const existing = currentAssignments[stableOwnerId];
const occupiedByOther = Object.entries(currentAssignments).find(
([otherStableOwnerId, otherAssignment]) =>
otherStableOwnerId !== stableOwnerId &&
otherAssignment.ringIndex === assignment.ringIndex &&
otherAssignment.sectorIndex === assignment.sectorIndex
);
if (
existing?.ringIndex === assignment.ringIndex &&
existing?.sectorIndex === assignment.sectorIndex &&
state.slotLayoutVersion === GRAPH_STABLE_SLOT_LAYOUT_VERSION
) {
return {};
}
if (occupiedByOther) {
logger.warn(
`[graph-layout] refusing occupied slot assignment team=${teamName} owner=${stableOwnerId} target=${assignment.ringIndex}:${assignment.sectorIndex} occupiedBy=${occupiedByOther[0]}`
);
return {};
}
return {
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
slotAssignmentsByTeam: {
...state.slotAssignmentsByTeam,
[teamName]: {
...currentAssignments,
[stableOwnerId]: assignment,
},
},
};
});
},
commitTeamGraphOwnerSlotDrop: (
teamName,
stableOwnerId,
assignment,
displacedStableOwnerId,
displacedAssignment
) => {
set((state) => {
const currentAssignments = state.slotAssignmentsByTeam[teamName] ?? {};
const existing = currentAssignments[stableOwnerId];
const nextAssignments: TeamGraphSlotAssignments = {
...currentAssignments,
[stableOwnerId]: assignment,
};
if (
existing?.ringIndex === assignment.ringIndex &&
existing?.sectorIndex === assignment.sectorIndex &&
!displacedStableOwnerId &&
state.slotLayoutVersion === GRAPH_STABLE_SLOT_LAYOUT_VERSION
) {
return {};
}
if (displacedStableOwnerId && displacedAssignment) {
nextAssignments[displacedStableOwnerId] = displacedAssignment;
}
const occupiedByConflict = Object.entries(nextAssignments).find(
([ownerId, nextAssignment]) => {
if (ownerId === stableOwnerId || ownerId === displacedStableOwnerId) {
return false;
}
return (
(nextAssignment.ringIndex === assignment.ringIndex &&
nextAssignment.sectorIndex === assignment.sectorIndex) ||
(displacedAssignment != null &&
nextAssignment.ringIndex === displacedAssignment.ringIndex &&
nextAssignment.sectorIndex === displacedAssignment.sectorIndex)
);
}
);
if (occupiedByConflict) {
logger.warn(
`[graph-layout] refusing slot drop team=${teamName} owner=${stableOwnerId} target=${assignment.ringIndex}:${assignment.sectorIndex} conflict=${occupiedByConflict[0]}`
);
return {};
}
return {
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
slotAssignmentsByTeam: {
...state.slotAssignmentsByTeam,
[teamName]: nextAssignments,
},
};
});
},
swapTeamGraphOwnerSlots: (teamName, stableOwnerId, otherStableOwnerId) => {
if (stableOwnerId === otherStableOwnerId) {
return;
}
set((state) => {
const currentAssignments = state.slotAssignmentsByTeam[teamName] ?? {};
const left = currentAssignments[stableOwnerId];
const right = currentAssignments[otherStableOwnerId];
if (!left || !right) {
return {};
}
return {
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
slotAssignmentsByTeam: {
...state.slotAssignmentsByTeam,
[teamName]: {
...currentAssignments,
[stableOwnerId]: right,
[otherStableOwnerId]: left,
},
},
};
});
},
clearTeamGraphSlotAssignments: (teamName) => {
set((state) => {
if (!teamName) {
if (
Object.keys(state.slotAssignmentsByTeam).length === 0 &&
state.slotLayoutVersion === GRAPH_STABLE_SLOT_LAYOUT_VERSION
) {
return {};
}
return {
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
slotAssignmentsByTeam: {},
};
}
if (!(teamName in state.slotAssignmentsByTeam)) {
return {};
}
const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam };
delete nextAssignmentsByTeam[teamName];
return {
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
slotAssignmentsByTeam: nextAssignmentsByTeam,
};
});
},
resetTeamGraphSlotAssignmentsToDefaults: (teamName) => {
set((state) => {
const currentAssignments = state.slotAssignmentsByTeam[teamName];
if (!currentAssignments || Object.keys(currentAssignments).length === 0) {
return {};
}
const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam };
delete nextAssignmentsByTeam[teamName];
return {
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
slotAssignmentsByTeam: nextAssignmentsByTeam,
};
});
},
setSelectedTeamTaskChangePresence: (teamName, taskId, presence) => { setSelectedTeamTaskChangePresence: (teamName, taskId, presence) => {
set((state) => { set((state) => {
const currentTeamData = selectTeamDataForName(state, teamName); const currentTeamData = selectTeamDataForName(state, teamName);

View file

@ -39,6 +39,7 @@ export interface TeamUpdateConfigRequest {
export interface TeamSummaryMember { export interface TeamSummaryMember {
name: string; name: string;
agentId?: string;
role?: string; role?: string;
color?: string; color?: string;
} }
@ -679,6 +680,7 @@ export type UpdateKanbanPatch =
export interface ResolvedTeamMember { export interface ResolvedTeamMember {
name: string; name: string;
agentId?: string;
status: MemberStatus; status: MemberStatus;
currentTaskId: string | null; currentTaskId: string | null;
taskCount: number; taskCount: number;

View file

@ -0,0 +1,12 @@
export interface StableTeamOwnerLike {
name: string;
agentId?: string | null;
}
export function getStableTeamOwnerId(member: StableTeamOwnerLike): string {
const agentId = member.agentId?.trim();
if (agentId) {
return agentId;
}
return member.name.trim();
}

View file

@ -13,9 +13,13 @@ const project = (id: string): DashboardRecentProject => ({
name: id, name: id,
primaryPath: `/tmp/${id}`, primaryPath: `/tmp/${id}`,
associatedPaths: [`/tmp/${id}`], associatedPaths: [`/tmp/${id}`],
primaryBranch: null, mostRecentActivity: Date.parse('2026-04-14T12:00:00.000Z'),
providerIds: ['anthropic'], providerIds: ['anthropic'],
updatedAt: '2026-04-14T12:00:00.000Z', source: 'claude',
openTarget: {
type: 'synthetic-path',
path: `/tmp/${id}`,
},
}); });
describe('recentProjectsClientCache', () => { describe('recentProjectsClientCache', () => {
@ -64,11 +68,15 @@ describe('recentProjectsClientCache', () => {
}); });
it('deduplicates concurrent client refreshes', async () => { it('deduplicates concurrent client refreshes', async () => {
let resolveLoader: ((projects: DashboardRecentProject[]) => void) | null = null; const resolveLoaderRef: {
current: ((projects: DashboardRecentProject[]) => void) | null;
} = {
current: null,
};
const loader = vi.fn( const loader = vi.fn(
() => () =>
new Promise<DashboardRecentProject[]>((resolve) => { new Promise<DashboardRecentProject[]>((resolve) => {
resolveLoader = resolve; resolveLoaderRef.current = resolve;
}) })
); );
@ -77,7 +85,7 @@ describe('recentProjectsClientCache', () => {
expect(loader).toHaveBeenCalledTimes(1); expect(loader).toHaveBeenCalledTimes(1);
resolveLoader?.([project('alpha')]); resolveLoaderRef.current?.([project('alpha')]);
await expect(first).resolves.toEqual([project('alpha')]); await expect(first).resolves.toEqual([project('alpha')]);
await expect(second).resolves.toEqual([project('alpha')]); await expect(second).resolves.toEqual([project('alpha')]);

View file

@ -2,28 +2,39 @@ import { describe, expect, it } from 'vitest';
import { resolveEffectiveSelectedRepositoryId } from '../../../../src/renderer/components/sidebar/dateGroupedSessionsSelection'; import { resolveEffectiveSelectedRepositoryId } from '../../../../src/renderer/components/sidebar/dateGroupedSessionsSelection';
import type { RepositoryGroup } from '@renderer/types/data';
function createRepositoryGroup(id: string, worktreeId: string, path: string): RepositoryGroup {
return {
id,
identity: null,
name: id,
totalSessions: 0,
worktrees: [
{
id: worktreeId,
path,
name: worktreeId,
isMainWorktree: true,
source: 'git',
sessions: [],
totalSessions: 0,
createdAt: 0,
},
],
};
}
describe('resolveEffectiveSelectedRepositoryId', () => { describe('resolveEffectiveSelectedRepositoryId', () => {
it('falls back to the repository that owns the active worktree when repository selection is empty', () => { it('falls back to the repository that owns the active worktree when repository selection is empty', () => {
const repositoryGroups = [ const repositoryGroups = [
{ createRepositoryGroup(
id: 'repo-headless', 'repo-headless',
worktrees: [ 'worktree-headless',
{ '/Users/belief/dev/projects/headless'
id: 'worktree-headless', ),
path: '/Users/belief/dev/projects/headless', createRepositoryGroup('repo-other', 'worktree-other', '/Users/belief/dev/projects/other'),
}, ];
],
},
{
id: 'repo-other',
worktrees: [
{
id: 'worktree-other',
path: '/Users/belief/dev/projects/other',
},
],
},
] as const;
expect( expect(
resolveEffectiveSelectedRepositoryId({ resolveEffectiveSelectedRepositoryId({
@ -36,11 +47,12 @@ describe('resolveEffectiveSelectedRepositoryId', () => {
it('keeps the explicit repository selection when it already exists', () => { it('keeps the explicit repository selection when it already exists', () => {
const repositoryGroups = [ const repositoryGroups = [
{ createRepositoryGroup(
id: 'repo-headless', 'repo-headless',
worktrees: [{ id: 'worktree-headless', path: '/Users/belief/dev/projects/headless' }], 'worktree-headless',
}, '/Users/belief/dev/projects/headless'
] as const; ),
];
expect( expect(
resolveEffectiveSelectedRepositoryId({ resolveEffectiveSelectedRepositoryId({
@ -53,11 +65,12 @@ describe('resolveEffectiveSelectedRepositoryId', () => {
it('falls back to the worktree owner when the explicit repository selection is stale', () => { it('falls back to the worktree owner when the explicit repository selection is stale', () => {
const repositoryGroups = [ const repositoryGroups = [
{ createRepositoryGroup(
id: 'repo-headless', 'repo-headless',
worktrees: [{ id: 'worktree-headless', path: '/Users/belief/dev/projects/headless' }], 'worktree-headless',
}, '/Users/belief/dev/projects/headless'
] as const; ),
];
expect( expect(
resolveEffectiveSelectedRepositoryId({ resolveEffectiveSelectedRepositoryId({
@ -70,15 +83,13 @@ describe('resolveEffectiveSelectedRepositoryId', () => {
it('prefers the repository that owns the active worktree over a different valid repository', () => { it('prefers the repository that owns the active worktree over a different valid repository', () => {
const repositoryGroups = [ const repositoryGroups = [
{ createRepositoryGroup(
id: 'repo-headless', 'repo-headless',
worktrees: [{ id: 'worktree-headless', path: '/Users/belief/dev/projects/headless' }], 'worktree-headless',
}, '/Users/belief/dev/projects/headless'
{ ),
id: 'repo-other', createRepositoryGroup('repo-other', 'worktree-other', '/Users/belief/dev/projects/other'),
worktrees: [{ id: 'worktree-other', path: '/Users/belief/dev/projects/other' }], ];
},
] as const;
expect( expect(
resolveEffectiveSelectedRepositoryId({ resolveEffectiveSelectedRepositoryId({

View file

@ -105,16 +105,13 @@ describe('teamProjectSelection', () => {
teamName: 'demo-team', teamName: 'demo-team',
displayName: 'Demo Team', displayName: 'Demo Team',
description: '', description: '',
color: null, memberCount: 0,
deletedAt: null, taskCount: 0,
pendingCreate: false,
partialLaunchFailure: false,
teamLaunchState: null,
projectPath: '/Users/test/other', projectPath: '/Users/test/other',
projectPathHistory: ['/Users/test/headless', '/Users/test/archive'], projectPathHistory: ['/Users/test/headless', '/Users/test/archive'],
lastActivity: null, lastActivity: null,
members: [], members: [],
} as TeamSummary; } satisfies TeamSummary;
expect(teamMatchesProjectSelection(team, '/users/test/headless')).toBe(true); expect(teamMatchesProjectSelection(team, '/users/test/headless')).toBe(true);
expect(teamMatchesProjectSelection(team, '/users/test/missing')).toBe(false); expect(teamMatchesProjectSelection(team, '/users/test/missing')).toBe(false);

View file

@ -2,6 +2,7 @@ import React, { act } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ACTIVITY_ANCHOR_LAYOUT } from '@claude-teams/agent-graph';
import { GraphActivityHud } from '@features/agent-graph/renderer/ui/GraphActivityHud'; import { GraphActivityHud } from '@features/agent-graph/renderer/ui/GraphActivityHud';
import type { GraphNode } from '@claude-teams/agent-graph'; import type { GraphNode } from '@claude-teams/agent-graph';
@ -37,6 +38,14 @@ const teamState = {
}; };
const buildInlineActivityEntries = vi.fn(); const buildInlineActivityEntries = vi.fn();
const originalOffsetWidthDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetWidth'
);
const originalOffsetHeightDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetHeight'
);
vi.mock('@renderer/store', () => ({ vi.mock('@renderer/store', () => ({
useStore: (selector: (state: typeof teamState) => unknown) => selector(teamState), useStore: (selector: (state: typeof teamState) => unknown) => selector(teamState),
@ -94,10 +103,35 @@ describe('GraphActivityHud', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
buildInlineActivityEntries.mockReset(); buildInlineActivityEntries.mockReset();
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1));
vi.stubGlobal('cancelAnimationFrame', vi.fn());
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true,
get() {
return 296;
},
});
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
configurable: true,
get() {
return 220;
},
});
}); });
afterEach(() => { afterEach(() => {
document.body.innerHTML = ''; document.body.innerHTML = '';
vi.unstubAllGlobals();
if (originalOffsetWidthDescriptor) {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidthDescriptor);
} else {
delete (HTMLElement.prototype as { offsetWidth?: number }).offsetWidth;
}
if (originalOffsetHeightDescriptor) {
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', originalOffsetHeightDescriptor);
} else {
delete (HTMLElement.prototype as { offsetHeight?: number }).offsetHeight;
}
}); });
it('opens the member profile on the Activity tab when +N more is clicked', async () => { it('opens the member profile on the Activity tab when +N more is clicked', async () => {
@ -221,4 +255,89 @@ describe('GraphActivityHud', () => {
await Promise.resolve(); await Promise.resolve();
}); });
}); });
it('keeps the activity lane above the owner label area when packed anchor drifts too low', async () => {
const message: InboxMessage = {
from: 'team-lead',
to: 'jack',
text: 'Latest log',
summary: 'Latest log',
timestamp: '2026-04-13T13:36:00.000Z',
read: false,
messageId: 'msg-latest',
};
buildInlineActivityEntries.mockReturnValue(
new Map([
[
'member:demo-team:jack',
[
{
ownerNodeId: 'member:demo-team:jack',
graphItem: {
id: 'item-1',
kind: 'inbox_message',
timestamp: message.timestamp,
title: message.summary ?? '',
},
message,
},
],
],
])
);
const node: GraphNode = {
id: 'member:demo-team:jack',
kind: 'member',
label: 'jack',
state: 'active',
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'jack' },
activityItems: [
{
id: 'item-1',
kind: 'inbox_message',
timestamp: message.timestamp,
title: 'Latest log',
},
],
activityOverflowCount: 0,
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const nodeWorld = { x: 320, y: 300 };
const packedAnchor = { x: 120, y: 260 };
await act(async () => {
root.render(
React.createElement(GraphActivityHud, {
teamName: 'demo-team',
nodes: [node],
getActivityAnchorScreenPlacement: () => ({ x: 40, y: 80, scale: 1, visible: true }),
getActivityAnchorWorldPosition: () => packedAnchor,
getNodeWorldPosition: () => nodeWorld,
getNodeScreenPosition: () => ({ x: 400, y: 300, visible: true }),
getViewportSize: () => ({ width: 1200, height: 800 }),
worldToScreen: (x: number, y: number) => ({ x, y }),
focusNodeIds: null,
})
);
await Promise.resolve();
});
const shell = host.querySelector('.z-10');
expect(shell).not.toBeNull();
const expectedTop =
nodeWorld.y +
ACTIVITY_ANCHOR_LAYOUT.memberOffsetY +
ACTIVITY_ANCHOR_LAYOUT.reservedHeight -
220;
expect((shell as HTMLDivElement).style.top).toBe(`${Math.round(expectedTop)}px`);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
}); });

View file

@ -0,0 +1,115 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@radix-ui/react-tooltip', () => ({
Root: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
Trigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
Portal: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
Content: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', { 'data-testid': 'tooltip-content' }, children),
Arrow: () => null,
}));
import { GraphControls } from '../../../../packages/agent-graph/src/ui/GraphControls';
describe('GraphControls', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('renders the sidebar toggle before the team and task buttons and triggers the callback', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onToggleSidebar = vi.fn();
await act(async () => {
root.render(
React.createElement(GraphControls, {
filters: {
showTasks: true,
showProcesses: true,
showEdges: true,
paused: false,
},
onFiltersChange: vi.fn(),
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onZoomToFit: vi.fn(),
onToggleSidebar,
isSidebarVisible: true,
onOpenTeamPage: vi.fn(),
onCreateTask: vi.fn(),
teamName: 'demo-team',
})
);
await Promise.resolve();
});
const labels = Array.from(host.querySelectorAll('button[aria-label]')).map((button) =>
button.getAttribute('aria-label')
);
expect(labels.indexOf('Hide sidebar')).toBeGreaterThanOrEqual(0);
expect(labels.indexOf('Open team page')).toBeGreaterThan(labels.indexOf('Hide sidebar'));
expect(labels.indexOf('Create task')).toBeGreaterThan(labels.indexOf('Open team page'));
const toggleButton = host.querySelector('button[aria-label="Hide sidebar"]');
expect(toggleButton).not.toBeNull();
await act(async () => {
toggleButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(onToggleSidebar).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('shows the open-sidebar label when the sidebar is hidden', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(GraphControls, {
filters: {
showTasks: true,
showProcesses: true,
showEdges: true,
paused: false,
},
onFiltersChange: vi.fn(),
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onZoomToFit: vi.fn(),
onToggleSidebar: vi.fn(),
isSidebarVisible: false,
teamName: 'demo-team',
})
);
await Promise.resolve();
});
expect(host.querySelector('button[aria-label="Show sidebar"]')).not.toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -169,6 +169,195 @@ describe('TeamGraphAdapter particles', () => {
expect(graph.particles).toHaveLength(0); expect(graph.particles).toHaveLength(0);
}); });
it('fails closed when visible members would silently merge on duplicate stable owner ids', () => {
const adapter = TeamGraphAdapter.create();
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const graph = adapter.adapt(
createBaseTeamData({
config: {
name: 'My Team',
members: [
{ name: 'team-lead' },
{ name: 'alice', agentId: 'shared-agent' },
{ name: 'bob', agentId: 'shared-agent' },
],
projectPath: '/repo',
},
members: [
{
name: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
agentType: 'team-lead',
agentId: 'lead-agent',
},
{
name: 'alice',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
agentId: 'shared-agent',
},
{
name: 'bob',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
agentId: 'shared-agent',
},
],
}),
'my-team'
);
expect(graph.nodes).toEqual([]);
expect(graph.edges).toEqual([]);
expect(errorSpy).toHaveBeenCalledWith(
'[agent-graph] duplicate stable owner ids in team=my-team: shared-agent'
);
errorSpy.mockRestore();
});
it('prioritizes owners with saved slot assignments before config-only members in layout order', () => {
const adapter = TeamGraphAdapter.create();
const graph = adapter.adapt(
createBaseTeamData({
config: {
name: 'My Team',
members: [
{ name: 'team-lead', agentId: 'lead-agent' },
{ name: 'bob', agentId: 'agent-bob' },
{ name: 'alice', agentId: 'agent-alice' },
],
projectPath: '/repo',
},
members: [
{
name: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
agentType: 'team-lead',
agentId: 'lead-agent',
},
{
name: 'alice',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
agentId: 'agent-alice',
},
{
name: 'bob',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
agentId: 'agent-bob',
},
],
}),
'my-team',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
}
);
expect(graph.layout?.ownerOrder).toEqual([
'member:my-team:agent-alice',
'member:my-team:agent-bob',
]);
});
it('keeps assigned owners ahead of config-only members even when the assigned owner is absent from config order', () => {
const adapter = TeamGraphAdapter.create();
const graph = adapter.adapt(
createBaseTeamData({
config: {
name: 'My Team',
members: [
{ name: 'team-lead', agentId: 'lead-agent' },
{ name: 'bob', agentId: 'agent-bob' },
],
projectPath: '/repo',
},
members: [
{
name: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
agentType: 'team-lead',
agentId: 'lead-agent',
},
{
name: 'alice',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
agentId: 'agent-alice',
},
{
name: 'bob',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
agentId: 'agent-bob',
},
],
}),
'my-team',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{
'agent-alice': { ringIndex: 1, sectorIndex: 4 },
}
);
expect(graph.layout?.ownerOrder).toEqual([
'member:my-team:agent-alice',
'member:my-team:agent-bob',
]);
});
it('does not replay old task comments that appear after the graph already opened', () => { it('does not replay old task comments that appear after the graph already opened', () => {
vi.setSystemTime(new Date('2026-03-28T19:00:10.000Z')); vi.setSystemTime(new Date('2026-03-28T19:00:10.000Z'));
@ -603,6 +792,123 @@ describe('TeamGraphAdapter particles', () => {
expect(graph.particles.every((particle) => particle.kind === 'inbox_message')).toBe(true); expect(graph.particles.every((particle) => particle.kind === 'inbox_message')).toBe(true);
}); });
it('keeps only one most relevant process rail per owner and prefers running over finished', () => {
const adapter = TeamGraphAdapter.create();
const graph = adapter.adapt(
createBaseTeamData({
members: [
{
name: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
agentType: 'team-lead',
},
{
name: 'alice',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
},
],
config: {
name: 'My Team',
members: [{ name: 'team-lead' }, { name: 'alice' }],
projectPath: '/repo',
},
processes: [
{
id: 'proc-finished',
label: 'Build API',
pid: 101,
registeredBy: 'alice',
registeredAt: '2026-03-28T19:00:01.000Z',
stoppedAt: '2026-03-28T19:00:10.000Z',
},
{
id: 'proc-running',
label: 'Watch dev server',
pid: 102,
registeredBy: 'alice',
registeredAt: '2026-03-28T19:00:02.000Z',
},
],
}),
'my-team'
);
const processNodes = graph.nodes.filter((node) => node.kind === 'process');
expect(processNodes).toHaveLength(1);
expect(processNodes[0]).toMatchObject({
id: 'process:my-team:proc-running',
ownerId: 'member:my-team:alice',
label: 'Watch dev server',
});
});
it('falls back to the most recent finished process when no running process exists', () => {
const adapter = TeamGraphAdapter.create();
const graph = adapter.adapt(
createBaseTeamData({
members: [
{
name: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
agentType: 'team-lead',
},
{
name: 'alice',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
},
],
config: {
name: 'My Team',
members: [{ name: 'team-lead' }, { name: 'alice' }],
projectPath: '/repo',
},
processes: [
{
id: 'proc-old-finished',
label: 'Older finished process',
pid: 101,
registeredBy: 'alice',
registeredAt: '2026-03-28T19:00:01.000Z',
stoppedAt: '2026-03-28T19:00:10.000Z',
},
{
id: 'proc-new-finished',
label: 'Newest finished process',
pid: 102,
registeredBy: 'alice',
registeredAt: '2026-03-28T19:00:03.000Z',
stoppedAt: '2026-03-28T19:00:11.000Z',
},
],
}),
'my-team'
);
const processNodes = graph.nodes.filter((node) => node.kind === 'process');
expect(processNodes).toHaveLength(1);
expect(processNodes[0]).toMatchObject({
id: 'process:my-team:proc-new-finished',
ownerId: 'member:my-team:alice',
label: 'Newest finished process',
});
});
it('derives graph launch visuals from shared provisioning semantics', () => { it('derives graph launch visuals from shared provisioning semantics', () => {
const adapter = TeamGraphAdapter.create(); const adapter = TeamGraphAdapter.create();
const graph = adapter.adapt( const graph = adapter.adapt(

View file

@ -94,6 +94,43 @@ describe('buildInlineActivityEntries', () => {
}); });
}); });
it('keeps same-timestamp inbox items in stable source order inside newest-first lanes', () => {
const data = createBaseTeamData({
messages: [
{
from: 'team-lead',
to: 'alice',
text: 'Second in source order',
timestamp: '2026-03-28T19:00:01.000Z',
read: false,
messageId: 'msg-b',
},
{
from: 'team-lead',
to: 'alice',
text: 'First in source order',
timestamp: '2026-03-28T19:00:01.000Z',
read: false,
messageId: 'msg-a',
},
],
});
const entries = buildInlineActivityEntries({
data,
teamName: 'my-team',
leadId: 'lead:my-team',
leadName: getGraphLeadMemberName(data, 'my-team'),
ownerNodeIds: new Set(['lead:my-team', 'member:my-team:alice', 'member:my-team:bob']),
});
const aliceEntries = entries.get('member:my-team:alice') ?? [];
expect(aliceEntries.map((entry) => entry.graphItem.id)).toEqual([
'activity:msg:my-team:msg-b',
'activity:msg:my-team:msg-a',
]);
});
it('builds synthetic comment messages that open with full task context and route owner-self comments to lead', () => { it('builds synthetic comment messages that open with full task context and route owner-self comments to lead', () => {
const data = createBaseTeamData({ const data = createBaseTeamData({
tasks: [ tasks: [

View file

@ -0,0 +1,111 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../../../packages/agent-graph/src/canvas/render-cache', async () => {
const actual = await vi.importActual<
typeof import('../../../../packages/agent-graph/src/canvas/render-cache')
>('../../../../packages/agent-graph/src/canvas/render-cache');
return {
...actual,
getAgentGlowSprite: vi.fn(() => ({ width: 1, height: 1 })),
};
});
import { drawAgents } from '../../../../packages/agent-graph/src/canvas/draw-agents';
import type { GraphNode } from '@claude-teams/agent-graph';
interface FillTextCall {
text: string;
x: number;
y: number;
}
function createMockContext() {
const fillTextCalls: FillTextCall[] = [];
const roundRectCalls: Array<{ x: number; y: number; width: number; height: number }> = [];
const gradient = { addColorStop: vi.fn() };
const ctx = {
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
closePath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
clip: vi.fn(),
drawImage: vi.fn(),
setLineDash: vi.fn(),
clearRect: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
roundRect: vi.fn((x: number, y: number, width: number, height: number) => {
roundRectCalls.push({ x, y, width, height });
}),
createRadialGradient: vi.fn(() => gradient),
createLinearGradient: vi.fn(() => gradient),
measureText: vi.fn((text: string) => ({ width: text.length * 4.5 })),
fillText: vi.fn((text: string, x: number, y: number) => {
fillTextCalls.push({ text, x, y });
}),
shadowColor: '',
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
font: '',
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline,
globalAlpha: 1,
} as unknown as CanvasRenderingContext2D;
return { ctx, fillTextCalls, roundRectCalls };
}
describe('drawAgents', () => {
it('renders the active tool card above the node while keeping labels below it', () => {
const { ctx, fillTextCalls, roundRectCalls } = createMockContext();
const node: GraphNode = {
id: 'member:demo:alice',
kind: 'member',
label: '2beacon-desk-22345',
state: 'tool_calling',
color: '#f5b74d',
runtimeLabel: 'Anthropic · Haiku 4.5 | Medium',
domainRef: { kind: 'member', teamName: 'demo', memberName: 'alice' },
activeTool: {
name: 'Bash',
preview: 'list_my_sessions',
state: 'running',
startedAt: '2026-04-15T10:00:00.000Z',
source: 'runtime',
},
x: 320,
y: 240,
};
drawAgents(ctx, [node], 0, null, null, null, 1);
const toolCard = roundRectCalls.find((call) => call.height === 18);
expect(toolCard).toBeDefined();
expect(toolCard!.y + toolCard!.height).toBeLessThan(node.y! - 1);
const labelCall = fillTextCalls.find((call) => call.text.includes('2beacon-desk-22345'));
const runtimeCall = fillTextCalls.find((call) => call.text.includes('Anthropic'));
const toolCall = fillTextCalls.find((call) => call.text.includes('Bash: list_my_sessions'));
expect(labelCall).toBeDefined();
expect(runtimeCall).toBeDefined();
expect(toolCall).toBeDefined();
expect(labelCall!.y).toBeGreaterThan(node.y!);
expect(runtimeCall!.y).toBeGreaterThan(labelCall!.y);
expect(toolCall!.y).toBeLessThan(node.y!);
});
});

View file

@ -1,152 +1,53 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { TASK_PILL } from '../../../../packages/agent-graph/src/constants/canvas-constants'; import { KanbanLayoutEngine } from '../../../../packages/agent-graph/src/layout/kanbanLayout';
import {
KanbanLayoutEngine,
getOwnerKanbanBaseX,
} from '../../../../packages/agent-graph/src/layout/kanbanLayout';
import {
ACTIVITY_LANE,
getActivityAnchorTarget,
getActivityLaneBounds,
} from '../../../../packages/agent-graph/src/layout/activityLane';
import type { GraphNode } from '@claude-teams/agent-graph'; import type { GraphNode } from '@claude-teams/agent-graph';
function createMemberNode(id: string, x: number, y: number, memberName: string): GraphNode { function createLead(teamName: string): GraphNode {
return { return {
id, id: `lead:${teamName}`,
kind: 'member',
label: memberName,
state: 'active',
x,
y,
domainRef: { kind: 'member', teamName: 'team', memberName },
};
}
function createLeadNode(x: number, y: number): GraphNode {
return {
id: 'lead:team',
kind: 'lead', kind: 'lead',
label: 'team lead', label: `${teamName}-lead`,
state: 'active', state: 'active',
x, x: 0,
y, y: 0,
domainRef: { kind: 'lead', teamName: 'team', memberName: 'lead' }, domainRef: { kind: 'lead', teamName, memberName: 'lead' },
}; };
} }
function createTaskNode( function createTask(teamName: string, taskId: string, ownerId?: string | null): GraphNode {
id: string,
ownerId: string,
status: NonNullable<GraphNode['taskStatus']>
): GraphNode {
return { return {
id, id: `task:${taskId}`,
kind: 'task', kind: 'task',
label: id, label: `#${taskId}`,
state: 'active', displayId: `#${taskId}`,
ownerId, state: 'idle',
taskStatus: status, ownerId: ownerId ?? null,
reviewState: 'none', taskStatus: 'pending',
domainRef: { kind: 'task', teamName: 'team', taskId: id }, domainRef: { kind: 'task', teamName, taskId },
}; };
} }
describe('kanban layout activity-lane avoidance', () => { describe('KanbanLayoutEngine', () => {
it('anchors right-side member kanban columns to the left of the owner', () => { it('routes tasks with missing owners into the unassigned lane', () => {
const baseX = getOwnerKanbanBaseX({ const teamName = 'team-kanban';
ownerX: 220, const lead = createLead(teamName);
ownerKind: 'member', const orphanTask = createTask(teamName, 'task-orphan', 'member:team-kanban:agent-missing');
activeColumnCount: 3,
columnWidth: 180, KanbanLayoutEngine.layout([lead, orphanTask], {
leadX: 0, unassignedTaskRect: {
left: -80,
top: 120,
right: 80,
bottom: 540,
width: 160,
height: 420,
},
}); });
expect(baseX).toBe(220 - 2 * 180); expect(orphanTask.x).toBe(0);
}); expect(orphanTask.y).toBe(120);
expect(KanbanLayoutEngine.zones.some((zone) => zone.ownerId === '__unassigned__')).toBe(true);
it('anchors left-side member kanban columns to the right of the owner', () => {
const baseX = getOwnerKanbanBaseX({
ownerX: -220,
ownerKind: 'member',
activeColumnCount: 3,
columnWidth: 180,
leadX: 0,
});
expect(baseX).toBe(-220);
});
it('keeps member task pills below the reserved activity lane', () => {
const lead = createLeadNode(0, 0);
const member = createMemberNode('member:jack', 220, 40, 'jack');
const tasks = [
createTaskNode('task:todo', member.id, 'pending'),
createTaskNode('task:wip', member.id, 'in_progress'),
createTaskNode('task:done', member.id, 'completed'),
];
KanbanLayoutEngine.layout([lead, member, ...tasks]);
const anchor = getActivityAnchorTarget({
nodeX: member.x ?? 0,
nodeY: member.y ?? 0,
nodeKind: 'member',
leadX: lead.x ?? null,
});
const laneBounds = getActivityLaneBounds(anchor.x, anchor.y);
const topmostTaskEdge = Math.min(...tasks.map((task) => (task.y ?? 0) - TASK_PILL.height / 2));
expect(topmostTaskEdge).toBeGreaterThan(laneBounds.bottom);
});
it('keeps left-side member task pills below the reserved activity lane', () => {
const lead = createLeadNode(0, 0);
const member = createMemberNode('member:alice', -220, 40, 'alice');
const tasks = [
createTaskNode('task:todo', member.id, 'pending'),
createTaskNode('task:wip', member.id, 'in_progress'),
createTaskNode('task:done', member.id, 'completed'),
];
KanbanLayoutEngine.layout([lead, member, ...tasks]);
const anchor = getActivityAnchorTarget({
nodeX: member.x ?? 0,
nodeY: member.y ?? 0,
nodeKind: 'member',
leadX: lead.x ?? null,
});
const laneBounds = getActivityLaneBounds(anchor.x, anchor.y);
const topmostTaskEdge = Math.min(...tasks.map((task) => (task.y ?? 0) - TASK_PILL.height / 2));
expect(topmostTaskEdge).toBeGreaterThan(laneBounds.bottom);
});
it('pushes task zones below overlapping activity lanes from nearby owners', () => {
const lead = createLeadNode(0, 0);
const member = createMemberNode('member:alice', 120, 120, 'alice');
const tasks = [
createTaskNode('task:todo', member.id, 'pending'),
createTaskNode('task:wip', member.id, 'in_progress'),
];
const nearbyLane = {
ownerId: 'member:tom',
left: 20,
top: -120,
right: 20 + ACTIVITY_LANE.width,
bottom: 180,
};
KanbanLayoutEngine.layout([lead, member, ...tasks], {
activityLaneBounds: [nearbyLane],
});
const topmostTaskEdge = Math.min(...tasks.map((task) => (task.y ?? 0) - TASK_PILL.height / 2));
expect(topmostTaskEdge).toBeGreaterThan(nearbyLane.bottom);
}); });
}); });

View file

@ -0,0 +1,105 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mockStore = vi.hoisted(() => {
const state = {
messagesPanelMode: 'inline' as 'sidebar' | 'inline' | 'bottom-sheet',
setMessagesPanelMode: vi.fn((mode: 'sidebar' | 'inline' | 'bottom-sheet') => {
state.messagesPanelMode = mode;
}),
};
return { state };
});
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: typeof mockStore.state) => unknown) => selector(mockStore.state),
}));
import { useGraphSidebarVisibility } from '@features/agent-graph/renderer/hooks/useGraphSidebarVisibility';
function HookProbe(): React.JSX.Element {
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
return React.createElement(
'button',
{
type: 'button',
onClick: toggleSidebarVisible,
'data-visible': sidebarVisible ? 'true' : 'false',
},
sidebarVisible ? 'visible' : 'hidden'
);
}
describe('useGraphSidebarVisibility', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
mockStore.state.messagesPanelMode = 'inline';
mockStore.state.setMessagesPanelMode.mockClear();
window.localStorage.clear();
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('forces sidebar mode on open when the messages panel is not currently in sidebar mode', async () => {
window.localStorage.setItem('team-graph-sidebar-visible', 'false');
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(HookProbe));
await Promise.resolve();
});
const button = host.querySelector('button');
expect(button?.getAttribute('data-visible')).toBe('false');
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(mockStore.state.setMessagesPanelMode).toHaveBeenCalledWith('sidebar');
expect(button?.getAttribute('data-visible')).toBe('true');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('hides the graph sidebar locally without changing the global messages panel mode', async () => {
mockStore.state.messagesPanelMode = 'sidebar';
window.localStorage.setItem('team-graph-sidebar-visible', 'true');
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(HookProbe));
await Promise.resolve();
});
const button = host.querySelector('button');
expect(button?.getAttribute('data-visible')).toBe('true');
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(mockStore.state.setMessagesPanelMode).not.toHaveBeenCalled();
expect(button?.getAttribute('data-visible')).toBe('false');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -1,86 +1,345 @@
import React, { act } from 'react'; import { describe, expect, it, vi } from 'vitest';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useGraphSimulation, type UseGraphSimulationResult } from '../../../../packages/agent-graph/src/hooks/useGraphSimulation'; import {
import { getLaunchAnchorTarget } from '../../../../packages/agent-graph/src/layout/launchAnchor'; buildStableSlotLayoutSnapshot,
computeOwnerFootprints,
resolveNearestSlotAssignment,
snapshotToWorldBounds,
validateStableSlotLayout,
} from '../../../../packages/agent-graph/src/layout/stableSlots';
import { STABLE_SLOT_GEOMETRY } from '../../../../packages/agent-graph/src/layout/stableSlotGeometry';
import { ACTIVITY_ANCHOR_LAYOUT } from '../../../../packages/agent-graph/src/layout/activityLane';
import type { GraphNode } from '@claude-teams/agent-graph'; import type { GraphLayoutPort, GraphNode } from '@claude-teams/agent-graph';
let capturedSimulation: UseGraphSimulationResult | null = null; function createLead(teamName: string): GraphNode {
return {
function SimulationHarness(): React.JSX.Element | null { id: `lead:${teamName}`,
capturedSimulation = useGraphSimulation(); kind: 'lead',
return null; label: `${teamName}-lead`,
state: 'active',
domainRef: { kind: 'lead', teamName, memberName: 'lead' },
};
} }
describe('useGraphSimulation launch anchor', () => { function createMember(teamName: string, stableOwnerId: string, memberName: string): GraphNode {
afterEach(() => { return {
capturedSimulation = null; id: `member:${teamName}:${stableOwnerId}`,
document.body.innerHTML = ''; kind: 'member',
label: memberName,
state: 'active',
domainRef: { kind: 'member', teamName, memberName },
};
}
function createTask(
teamName: string,
taskId: string,
ownerId?: string | null,
overrides?: Partial<GraphNode>
): GraphNode {
return {
id: `task:${taskId}`,
kind: 'task',
label: `#${taskId}`,
displayId: `#${taskId}`,
state: 'idle',
ownerId: ownerId ?? null,
taskStatus: 'pending',
domainRef: { kind: 'task', teamName, taskId },
...overrides,
};
}
describe('stable slot layout planner', () => {
it('does not build a stable slot snapshot when the lead is missing', () => {
const snapshot = buildStableSlotLayoutSnapshot({
teamName: 'team-no-lead',
nodes: [createMember('team-no-lead', 'agent-alice', 'alice')],
layout: {
version: 'stable-slots-v1',
ownerOrder: ['member:team-no-lead:agent-alice'],
slotAssignments: {
'member:team-no-lead:agent-alice': { ringIndex: 0, sectorIndex: 1 },
},
},
});
expect(snapshot).toBeNull();
}); });
it('keeps the launch anchor aligned when the lead is dragged after settling', async () => { it('builds launch and activity geometry around the central lead block', () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const teamName = 'team-a';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const host = document.createElement('div'); const snapshot = buildStableSlotLayoutSnapshot({
document.body.appendChild(host); teamName,
const root = createRoot(host); nodes: [lead, alice],
layout,
await act(async () => {
root.render(React.createElement(SimulationHarness));
await Promise.resolve();
}); });
const lead: GraphNode = { expect(snapshot).not.toBeNull();
id: 'lead:team-a', expect(snapshot?.leadNodeId).toBe(lead.id);
kind: 'lead', expect(snapshot?.launchAnchor).not.toBeNull();
label: 'team-a', expect(snapshot?.memberSlotFrames).toHaveLength(1);
state: 'active', expect(snapshot?.memberSlotFrames[0]?.ownerId).toBe(alice.id);
domainRef: { kind: 'lead', teamName: 'team-a', memberName: 'lead' }, expect(snapshot?.leadActivityRect.left).toBeLessThan(snapshot?.leadCoreRect.left ?? 0);
expect(snapshot?.fitBounds.right).toBeGreaterThan(snapshot?.leadCoreRect.right ?? 0);
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
});
it('keeps a fixed process rail width centered inside the owner slot', () => {
const teamName = 'team-process-width';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
}; };
const member: GraphNode = { const snapshot = buildStableSlotLayoutSnapshot({
id: 'member:team-a:alice', teamName,
kind: 'member', nodes: [lead, alice],
label: 'alice', layout,
state: 'active', });
domainRef: { kind: 'member', teamName: 'team-a', memberName: 'alice' },
const frame = snapshot?.memberSlotFrames[0];
expect(frame).toBeDefined();
expect(frame?.processBandRect.width).toBe(STABLE_SLOT_GEOMETRY.processRailWidth);
expect(frame?.processBandRect.left).toBeCloseTo(
(frame?.bounds.left ?? 0) + ((frame?.bounds.width ?? 0) - STABLE_SLOT_GEOMETRY.processRailWidth) / 2,
6
);
});
it('includes full topology bounds for fit, not only activity overlays', () => {
const teamName = 'team-fit';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
}; };
await act(async () => { const snapshot = buildStableSlotLayoutSnapshot({
capturedSimulation?.updateData( teamName,
[lead, member], nodes: [lead, alice],
[ layout,
});
const bounds = snapshotToWorldBounds(snapshot!);
expect(bounds[0]).toEqual({
left: snapshot!.fitBounds.left,
top: snapshot!.fitBounds.top,
right: snapshot!.fitBounds.right,
bottom: snapshot!.fitBounds.bottom,
});
});
it('rejects invalid overlapping slot frames in validation pass', () => {
const teamName = 'team-invalid';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const bob = createMember(teamName, 'agent-bob', 'bob');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id, bob.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
[bob.id]: { ringIndex: 0, sectorIndex: 2 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice, bob],
layout,
});
expect(snapshot).not.toBeNull();
const [firstFrame] = snapshot!.memberSlotFrames;
const invalid = {
...snapshot!,
memberSlotFrames: snapshot!.memberSlotFrames.map((frame, index) =>
index === 1
? {
...frame,
bounds: firstFrame.bounds,
}
: frame
),
};
expect(validateStableSlotLayout(invalid).valid).toBe(false);
});
it('prefers the occupied target slot when dragging near another owner anchor', () => {
const teamName = 'team-b';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const bob = createMember(teamName, 'agent-bob', 'bob');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id, bob.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
[bob.id]: { ringIndex: 0, sectorIndex: 2 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice, bob],
layout,
});
expect(snapshot).not.toBeNull();
const bobFrame = snapshot?.memberSlotFrames.find((frame) => frame.ownerId === bob.id);
expect(bobFrame).toBeDefined();
const nearest = resolveNearestSlotAssignment({
ownerId: alice.id,
ownerX: bobFrame?.ownerX ?? 0,
ownerY: bobFrame?.ownerY ?? 0,
nodes: [lead, alice, bob],
snapshot: snapshot!,
layout,
});
expect(nearest).not.toBeNull();
expect(nearest?.assignment).toEqual({ ringIndex: 0, sectorIndex: 2 });
expect(nearest?.displacedOwnerId).toBe(bob.id);
expect(nearest?.displacedAssignment).toEqual({ ringIndex: 0, sectorIndex: 1 });
});
it('treats tasks with missing owner nodes as unassigned topology actors', () => {
const teamName = 'team-orphan-task';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const orphanTask = createTask(teamName, 'task-orphan', 'member:team-orphan-task:agent-missing');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice, orphanTask],
layout,
});
expect(snapshot).not.toBeNull();
expect(snapshot?.unassignedTaskRect).not.toBeNull();
});
it('computes the next ring radius from previous ring depth, not member count', () => {
const teamName = 'team-ring-depth';
const lead = createLead(teamName);
const members = Array.from({ length: 7 }, (_, index) =>
createMember(teamName, `agent-${index + 1}`, `member-${index + 1}`)
);
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: members.map((member) => member.id),
slotAssignments: Object.fromEntries(
members.map((member, index) => [
member.id,
{ {
id: 'edge:lead:alice', ringIndex: index < 6 ? 0 : 1,
source: lead.id, sectorIndex: index % 6,
target: member.id,
type: 'parent-child',
}, },
], ])
[] ),
); };
capturedSimulation?.tick(0);
await Promise.resolve(); const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, ...members],
layout,
}); });
const footprints = computeOwnerFootprints([lead, ...members], layout);
expect(capturedSimulation?.getLaunchAnchorWorldPosition(lead.id)).not.toBeNull(); const firstRingFrame = snapshot?.memberSlotFrames.find(
expect(capturedSimulation?.getExtraWorldBounds()).toHaveLength(3); (frame) => frame.ringIndex === 0 && frame.sectorIndex === 0
);
await act(async () => { const secondRingFrame = snapshot?.memberSlotFrames.find(
capturedSimulation?.setNodePosition(lead.id, 140, 60); (frame) => frame.ringIndex === 1 && frame.sectorIndex === 0
capturedSimulation?.tick(0);
await Promise.resolve();
});
expect(capturedSimulation?.getLaunchAnchorWorldPosition(lead.id)).toEqual(
getLaunchAnchorTarget(140, 60)
); );
await act(async () => { expect(snapshot).not.toBeNull();
root.unmount(); expect(firstRingFrame).toBeDefined();
await Promise.resolve(); expect(secondRingFrame).toBeDefined();
const firstFootprint = footprints[0];
expect(firstFootprint).toBeDefined();
if (!firstFootprint) {
throw new Error('expected first footprint for ring-depth test');
}
const ringDelta = Math.hypot(secondRingFrame!.ownerX, secondRingFrame!.ownerY)
- Math.hypot(firstRingFrame!.ownerX, firstRingFrame!.ownerY);
const ownerAnchorOffsetY =
STABLE_SLOT_GEOMETRY.memberSlotInnerPadding +
ACTIVITY_ANCHOR_LAYOUT.reservedHeight +
STABLE_SLOT_GEOMETRY.slotVerticalGap +
STABLE_SLOT_GEOMETRY.ownerBandHeight / 2;
const expectedRingDelta =
ownerAnchorOffsetY +
(firstFootprint.slotHeight - ownerAnchorOffsetY) +
STABLE_SLOT_GEOMETRY.ringGap;
expect(ringDelta).toBeCloseTo(expectedRingDelta, 6);
});
it('keeps the same sector and spills to the next outer ring when the saved slot is already occupied', () => {
const teamName = 'team-wide-spill';
const lead = createLead(teamName);
const narrow = createMember(teamName, 'agent-narrow', 'narrow');
const wide = createMember(teamName, 'agent-wide', 'wide');
const wideTasks = [
createTask(teamName, 'todo', wide.id, { taskStatus: 'pending' }),
createTask(teamName, 'wip', wide.id, { taskStatus: 'in_progress' }),
createTask(teamName, 'done', wide.id, { taskStatus: 'completed' }),
createTask(teamName, 'review', wide.id, { reviewState: 'review' }),
createTask(teamName, 'approved', wide.id, { reviewState: 'approved' }),
];
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
ownerOrder: [narrow.id, wide.id],
slotAssignments: {
[narrow.id]: { ringIndex: 0, sectorIndex: 1 },
[wide.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, narrow, wide, ...wideTasks],
layout,
}); });
const wideFrame = snapshot?.memberSlotFrames.find((frame) => frame.ownerId === wide.id);
const warnMock = vi.mocked(console.warn);
expect(snapshot).not.toBeNull();
expect(wideFrame).toBeDefined();
expect(wideFrame?.ringIndex).toBe(1);
expect(wideFrame?.sectorIndex).toBe(1);
expect(warnMock.mock.calls).toHaveLength(1);
warnMock.mockClear();
}); });
}); });

View file

@ -221,6 +221,88 @@ describe('teamSlice actions', () => {
expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled(); expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled();
}); });
it('commits an owner slot drop atomically even when prior assignments were sparse', () => {
const store = createSliceStore();
store.getState().commitTeamGraphOwnerSlotDrop(
'my-team',
'agent-alice',
{ ringIndex: 0, sectorIndex: 2 },
'agent-bob',
{ ringIndex: 0, sectorIndex: 1 }
);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
'agent-bob': { ringIndex: 0, sectorIndex: 1 },
});
});
it('migrates fallback name-based slot assignments to agentId-based stable owner ids', () => {
const store = createSliceStore();
store.setState({
slotLayoutVersion: 'stable-slots-v1',
slotAssignmentsByTeam: {
'my-team': {
alice: { ringIndex: 0, sectorIndex: 3 },
},
},
});
store.getState().ensureTeamGraphSlotAssignments('my-team', [
{ name: 'alice', agentId: 'agent-alice' },
{ name: 'bob', agentId: 'agent-bob' },
]);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 3 },
});
});
it('resets stale slot assignments when slot layout version mismatches', () => {
const store = createSliceStore();
store.setState({
slotLayoutVersion: 'legacy-layout-version',
slotAssignmentsByTeam: {
'other-team': {
'agent-old': { ringIndex: 9, sectorIndex: 9 },
},
'my-team': {
alice: { ringIndex: 0, sectorIndex: 1 },
},
},
});
store.getState().ensureTeamGraphSlotAssignments('my-team', [
{ name: 'alice', agentId: 'agent-alice' },
]);
expect(store.getState().slotLayoutVersion).toBe('stable-slots-v1');
expect(store.getState().slotAssignmentsByTeam).toEqual({});
});
it('keeps hidden-member slot assignments so the same stable owner can reuse them later', () => {
const store = createSliceStore();
store.setState({
slotLayoutVersion: 'stable-slots-v1',
slotAssignmentsByTeam: {
'my-team': {
'agent-hidden': { ringIndex: 1, sectorIndex: 5 },
'agent-visible': { ringIndex: 0, sectorIndex: 2 },
},
},
});
store.getState().ensureTeamGraphSlotAssignments('my-team', [
{ name: 'visible', agentId: 'agent-visible' },
]);
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-hidden': { ringIndex: 1, sectorIndex: 5 },
'agent-visible': { ringIndex: 0, sectorIndex: 2 },
});
});
it('syncs both team and graph tab labels when the team display name changes', async () => { it('syncs both team and graph tab labels when the team display name changes', async () => {
const store = createSliceStore(); const store = createSliceStore();
const getAllPaneTabs = vi.fn(() => [ const getAllPaneTabs = vi.fn(() => [

View file

@ -13,6 +13,7 @@
"noEmit": true, "noEmit": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@claude-teams/agent-graph": ["./packages/agent-graph/src/index.ts"],
"@features/*": ["./src/features/*"], "@features/*": ["./src/features/*"],
"@main/*": ["./src/main/*"], "@main/*": ["./src/main/*"],
"@renderer/*": ["./src/renderer/*"], "@renderer/*": ["./src/renderer/*"],

View file

@ -12,6 +12,7 @@
"noEmit": true, "noEmit": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@claude-teams/agent-graph": ["./packages/agent-graph/src/index.ts"],
"@features/*": ["./src/features/*"], "@features/*": ["./src/features/*"],
"@main/*": ["./src/main/*"], "@main/*": ["./src/main/*"],
"@preload/*": ["./src/preload/*"], "@preload/*": ["./src/preload/*"],

View file

@ -22,6 +22,9 @@ export default defineConfig({
'@main': resolve(__dirname, 'src/main'), '@main': resolve(__dirname, 'src/main'),
'@renderer': resolve(__dirname, 'src/renderer'), '@renderer': resolve(__dirname, 'src/renderer'),
'@preload': resolve(__dirname, 'src/preload'), '@preload': resolve(__dirname, 'src/preload'),
'@claude-teams/agent-graph': resolve(__dirname, 'packages/agent-graph/src/index.ts'),
react: resolve(__dirname, 'node_modules/react'),
'react-dom': resolve(__dirname, 'node_modules/react-dom'),
}, },
}, },
}); });