From 2e062e44327fe7e791e384b4647bbeba9530ae2e Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 17:28:27 +0300 Subject: [PATCH] fix(agent-graph): route lead tasks and tighten lead spacing --- .../agent-graph/src/layout/stableSlots.ts | 39 ++++++++++--- .../renderer/adapters/TeamGraphAdapter.ts | 41 ++++++++++++- .../agent-graph/TeamGraphAdapter.test.ts | 57 +++++++++++++++++++ .../agent-graph/useGraphSimulation.test.ts | 34 +++++++++++ 4 files changed, 161 insertions(+), 10 deletions(-) diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index 070323ea..a0c31ab9 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -173,12 +173,16 @@ export function buildStableSlotLayoutSnapshot({ ); const leadActivityRect = leadSlotFrame.activityColumnRect; const launchHudRect = createRect(leadCoreRect.right, leadCoreRect.top, 0, 0); - const leadCentralReservedBlock = leadSlotFrame.bounds; + const leadCentralReservedBlock = buildLeadCentralReservedBlock({ + leadCoreRect, + leadSlotFrame, + }); const ownerFootprints = computeOwnerFootprints(nodes, layout); const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock); const centralCollisionRects = buildCentralCollisionRects({ - leadCentralReservedBlock, + leadCoreRect, + leadSlotFrame, unassignedTaskRect, }); const runtimeCentralExclusion = padRect( @@ -222,16 +226,34 @@ export function buildStableSlotLayoutSnapshot({ } function buildCentralCollisionRects(args: { - leadCentralReservedBlock: StableRect; + leadCoreRect: StableRect; + leadSlotFrame: SlotFrame; unassignedTaskRect: StableRect | null; }): StableRect[] { - const rects = [args.leadCentralReservedBlock]; + const rects = [ + args.leadCoreRect, + args.leadSlotFrame.processBandRect, + args.leadSlotFrame.activityColumnRect, + args.leadSlotFrame.kanbanBandRect, + ]; if (args.unassignedTaskRect) { rects.push(args.unassignedTaskRect); } return rects; } +function buildLeadCentralReservedBlock(args: { + leadCoreRect: StableRect; + leadSlotFrame: SlotFrame; +}): StableRect { + return unionRects([ + args.leadCoreRect, + args.leadSlotFrame.processBandRect, + args.leadSlotFrame.activityColumnRect, + args.leadSlotFrame.kanbanBandRect, + ]); +} + function padCentralCollisionRects( rects: readonly StableRect[], padding: number @@ -648,6 +670,12 @@ function validateLeadSnapshotRects( if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) { return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' }; } + if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.processBandRect)) { + return { valid: false, reason: 'lead processBandRect must fit inside leadCentralReservedBlock' }; + } + if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.kanbanBandRect)) { + return { valid: false, reason: 'lead kanbanBandRect must fit inside leadCentralReservedBlock' }; + } if (snapshot.leadActivityRect.left !== snapshot.leadSlotFrame.activityColumnRect.left) { return { valid: false, @@ -660,9 +688,6 @@ function validateLeadSnapshotRects( reason: 'leadActivityRect must mirror leadSlotFrame.activityColumnRect', }; } - if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.bounds)) { - return { valid: false, reason: 'leadSlotFrame must fit inside leadCentralReservedBlock' }; - } if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) { return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' }; } diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 63002e5d..cfbcbaa5 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -175,7 +175,16 @@ export class TeamGraphAdapter { isTeamProvisioning, isLaunchSettling ); - this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState, memberNodeIdByAlias); + this.#buildTaskNodes( + nodes, + edges, + teamData, + teamName, + commentReadState, + memberNodeIdByAlias, + leadId, + leadName + ); this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias); this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName); this.#buildMessageParticles( @@ -554,7 +563,9 @@ export class TeamGraphAdapter { data: TeamData, teamName: string, commentReadState?: Record, - memberNodeIdByAlias?: ReadonlyMap + memberNodeIdByAlias?: ReadonlyMap, + leadId?: string, + leadName?: string ): void { const taskStateById = new Map>(); const taskDisplayIds = new Map(); @@ -575,7 +586,12 @@ export class TeamGraphAdapter { for (const task of data.tasks) { if (task.status === 'deleted') continue; const taskId = `task:${teamName}:${task.id}`; - const ownerMemberId = task.owner ? (memberNodeIdByAlias?.get(task.owner) ?? null) : null; + const ownerMemberId = + leadId && memberNodeIdByAlias + ? TeamGraphAdapter.#resolveTaskOwnerId(task.owner, leadId, leadName, memberNodeIdByAlias) + : task.owner + ? (memberNodeIdByAlias?.get(task.owner) ?? null) + : null; const kanbanTaskState = data.kanbanState.tasks[task.id]; const reviewerName = resolveTaskReviewer(task, kanbanTaskState); const isReviewCycle = isTaskInReviewCycle(task); @@ -1228,6 +1244,25 @@ export class TeamGraphAdapter { return memberNodeIdByAlias.get(name) ?? leadId; } + static #resolveTaskOwnerId( + ownerName: string | null | undefined, + leadId: string, + leadName: string | undefined, + memberNodeIdByAlias: ReadonlyMap + ): string | null { + if (!ownerName?.trim()) { + return null; + } + const normalized = ownerName.trim().toLowerCase(); + if (normalized === 'user' || normalized === 'team-lead') { + return leadId; + } + if (normalized === leadName?.trim().toLowerCase()) { + return leadId; + } + return memberNodeIdByAlias.get(ownerName) ?? null; + } + /** Extract external team name from cross-team "from" field like "team-b.alice" */ static #extractExternalTeamName(from: string): string | null { const dotIdx = from.indexOf('.'); diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 77173f65..78bd54ac 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -713,6 +713,63 @@ describe('TeamGraphAdapter particles', () => { ]); }); + it('maps lead-owned tasks onto the lead board without routing unknown owners to lead', () => { + const adapter = TeamGraphAdapter.create(); + + const graph = adapter.adapt( + createBaseTeamData({ + config: { + name: 'My Team', + members: [{ name: 'olivia', agentType: 'lead' }, { name: 'alice' }], + projectPath: '/repo', + }, + members: [ + { + name: 'olivia', + status: 'active', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + agentType: 'lead', + }, + { + name: 'alice', + status: 'active', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + }, + ], + tasks: [ + { + id: 'lead-task', + displayId: '#11', + subject: 'Lead summary', + owner: 'olivia', + status: 'in_progress', + comments: [], + reviewState: 'none', + } as TeamTaskWithKanban, + { + id: 'unknown-task', + displayId: '#12', + subject: 'Unknown owner', + owner: 'ghost', + status: 'in_progress', + comments: [], + reviewState: 'none', + } as TeamTaskWithKanban, + ], + }), + 'my-team' + ); + + expect(findNode(graph, 'task:my-team:lead-task')?.ownerId).toBe('lead:my-team'); + expect(findNode(graph, 'task:my-team:unknown-task')?.ownerId).toBeNull(); + }); + it('builds member activity feeds from inbox messages in newest-first order', () => { const adapter = TeamGraphAdapter.create(); diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index a485d673..ea22cb6e 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -797,6 +797,40 @@ describe('stable slot layout planner', () => { } }); + it('builds central collisions from occupied lead sub-rects instead of the full lead slot bounds', () => { + const teamName = 'team-lead-central-collision'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const leadTasks = [ + createTask(teamName, 'lead-a', lead.id, { taskStatus: 'completed' }), + createTask(teamName, 'lead-b', lead.id, { taskStatus: 'in_progress' }), + ]; + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const nodes = [lead, alice, ...leadTasks]; + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes, + layout, + }); + + expect(snapshot).not.toBeNull(); + expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadCoreRect); + expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.processBandRect); + expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.activityColumnRect); + expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.kanbanBandRect); + expect(snapshot!.leadCentralReservedBlock.width).toBeLessThan(snapshot!.leadSlotFrame.bounds.width); + expect(snapshot!.leadCentralReservedBlock.height).toBeLessThan( + snapshot!.leadSlotFrame.bounds.height + ); + }); + 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);