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:
parent
363fef224d
commit
aed08113e6
52 changed files with 7258 additions and 1027 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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__:';
|
||||||
|
|
|
||||||
158
packages/agent-graph/src/layout/stableSlotGeometry.ts
Normal file
158
packages/agent-graph/src/layout/stableSlotGeometry.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
1291
packages/agent-graph/src/layout/stableSlots.ts
Normal file
1291
packages/agent-graph/src/layout/stableSlots.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
2846
src/features/agent-graph/STABLE_SLOT_LAYOUT_PLAN.md
Normal file
2846
src/features/agent-graph/STABLE_SLOT_LAYOUT_PLAN.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 {
|
||||||
|
|
|
||||||
30
src/features/agent-graph/core/domain/graphOwnerIdentity.ts
Normal file
30
src/features/agent-graph/core/domain/graphOwnerIdentity.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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" */
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 & {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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$/, '');
|
||||||
|
|
|
||||||
|
|
@ -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)' }}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
12
src/shared/utils/teamStableOwnerId.ts
Normal file
12
src/shared/utils/teamStableOwnerId.ts
Normal 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();
|
||||||
|
}
|
||||||
|
|
@ -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')]);
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
115
test/renderer/features/agent-graph/GraphControls.test.ts
Normal file
115
test/renderer/features/agent-graph/GraphControls.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
111
test/renderer/features/agent-graph/drawAgents.test.ts
Normal file
111
test/renderer/features/agent-graph/drawAgents.test.ts
Normal 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!);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(() => [
|
||||||
|
|
|
||||||
|
|
@ -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/*"],
|
||||||
|
|
|
||||||
|
|
@ -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/*"],
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue