Show directional lineage in agent map (#13503)

* Show directional lineage in agent map

* Route agent lineage links diagonally

* Guard agent map lineage path bounds

* Avoid chevrons in undersized lineage gaps
This commit is contained in:
Brennan Benson
2026-08-10 00:16:21 -07:00
committed by GitHub
parent abd160fadd
commit 0fa3a5bd1d
7 changed files with 145 additions and 19 deletions
@@ -48,7 +48,7 @@ type AgentDashboardToolbarProps = {
showAgentlessWorkspaces?: boolean
agentlessWorkspaceCount?: number
onShowAgentlessWorkspacesChange?: (show: boolean) => void
/** Map-only: the dashed parent→child dispatch edges. */
/** Map-only: the directional parent→child dispatch edges. */
showOrchestrationLinks?: boolean
onShowOrchestrationLinksChange?: (show: boolean) => void
searchInputRef: React.RefObject<HTMLInputElement | null>
@@ -361,11 +361,13 @@ describe('AgentMap', () => {
expect(workerLink).toHaveClass('agent-map-lineage-link')
expect(workerLink).toHaveAttribute('data-agent-map-lineage-relation', 'orchestration')
expect(workerLink).toHaveAttribute('data-parent-pane-key', 'parent')
expect(workerLink?.getAttribute('d')?.match(/\bM\b/g)?.length).toBeGreaterThan(1)
// Why orchestration, not subagent: `nested` is a card, and in-process subagents
// never become cards — so a grandchild dispatch is still an orchestration edge.
expect(nestedLink).toHaveClass('agent-map-lineage-link')
expect(nestedLink).toHaveAttribute('data-agent-map-lineage-relation', 'orchestration')
expect(nestedLink).toHaveAttribute('data-parent-pane-key', 'child')
expect(nestedLink?.getAttribute('d')?.match(/\bM\b/g)?.length).toBeGreaterThan(1)
})
it('connects spawned workers across worktree rings', () => {
@@ -12,6 +12,7 @@ import type {
} from './agent-map-layout'
import { AGENT_MAP_LINEAGE_RELATION, shouldAggregateAgentMapWorktree } from './agent-map-layout'
import { selectVisibleAgentMapLabels } from './agent-map-label-declutter'
import { agentMapDirectLineageChevronPath } from './agent-map-lineage-chevron-path'
import { AgentMapWorktreeLabel } from './AgentMapWorktreeLabel'
import { AgentMapWorktreeRingNode } from './AgentMapWorktreeRingNode'
@@ -52,15 +53,7 @@ type VisibleAgentLocation = {
}
function agentLineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): string {
const dx = child.x - parent.x
const dy = child.y - parent.y
const distance = Math.hypot(dx, dy)
if (distance === 0) {
return `M ${parent.x} ${parent.y}`
}
const unitX = dx / distance
const unitY = dy / distance
return `M ${parent.x + unitX * parent.radius} ${parent.y + unitY * parent.radius} L ${child.x - unitX * child.radius} ${child.y - unitY * child.radius}`
return agentMapDirectLineageChevronPath(parent, child)
}
/** Memoization keeps pointer panning to one SVG viewBox write, not a map rerender. */
@@ -14,6 +14,7 @@ import type {
AgentMapWorktreeRing
} from './agent-map-layout'
import { AGENT_MAP_LINEAGE_RELATION, shouldAggregateAgentMapWorktree } from './agent-map-layout'
import { agentMapDirectLineageChevronPath } from './agent-map-lineage-chevron-path'
import { agentMapWorktreeActiveStatus } from './agent-map-worktree-active-status'
type AgentMapWorktreeRingNodeProps = {
@@ -50,15 +51,8 @@ function formatDuration(minutes: number): string {
})
}
// Why direction-aware: packing can place a child level with or above its parent, and
// a fixed downward elbow would then exit the wrong side and draw back through both
// nodes. Leaving the rank tie at +1 keeps the common downward case byte-identical.
function lineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): string {
const direction = child.y < parent.y ? -1 : 1
const startY = parent.y + parent.radius * direction
const endY = child.y - child.radius * direction
const branchY = (startY + endY) / 2
return `M ${parent.x} ${startY} L ${parent.x} ${branchY} L ${child.x} ${branchY} L ${child.x} ${endY}`
return agentMapDirectLineageChevronPath(parent, child)
}
function agentName(card: DashboardCard): string {
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { agentMapDirectLineageChevronPath } from './agent-map-lineage-chevron-path'
describe('agentMapDirectLineageChevronPath', () => {
it('runs every chevron directly from the parent toward the child', () => {
const path = agentMapDirectLineageChevronPath(
{ x: 0, y: 0, radius: 4 },
{ x: 40, y: 40, radius: 4 }
)
const tips = [...path.matchAll(/M [-\d.]+ [-\d.]+ L ([-\d.]+) ([-\d.]+) L/g)].map((match) => ({
x: Number(match[1]),
y: Number(match[2])
}))
expect(tips.length).toBeGreaterThan(1)
expect(tips.every((tip) => tip.x === tip.y)).toBe(true)
expect(tips.at(-1)?.x).toBeGreaterThan(tips[0].x)
})
it('trims the path to unequal node radii', () => {
expect(
agentMapDirectLineageChevronPath({ x: 0, y: 0, radius: 2 }, { x: 20, y: 0, radius: 6 })
).toBe('M 4.5 2.25 L 8 0 L 4.5 -2.25')
})
it('does not reverse direction when node boundaries overlap', () => {
expect(
agentMapDirectLineageChevronPath({ x: 0, y: 0, radius: 10 }, { x: 15, y: 0, radius: 10 })
).toBe('M 0 0')
})
it('omits a chevron that cannot fit between trimmed node boundaries', () => {
expect(
agentMapDirectLineageChevronPath({ x: 0, y: 0, radius: 10 }, { x: 25, y: 0, radius: 10 })
).toBe('M 10 0')
})
it('caps decorative chevrons on long links', () => {
const path = agentMapDirectLineageChevronPath(
{ x: 0, y: 0, radius: 0 },
{ x: 10_000, y: 0, radius: 0 }
)
expect(path.match(/\bM\b/g)).toHaveLength(32)
})
})
@@ -0,0 +1,92 @@
export type AgentMapLineagePoint = {
x: number
y: number
}
type AgentMapLineageNode = AgentMapLineagePoint & {
radius: number
}
type LineageSegment = {
start: AgentMapLineagePoint
unitX: number
unitY: number
length: number
}
const CHEVRON_SPACING = 8
const CHEVRON_DEPTH = 3.5
const CHEVRON_HALF_WIDTH = 2.25
const MAX_CHEVRONS_PER_PATH = 32
function svgNumber(value: number): number {
return Math.round(value * 1_000) / 1_000
}
export function agentMapLineageChevronPath(points: AgentMapLineagePoint[]): string {
const segments: LineageSegment[] = []
let totalLength = 0
for (let index = 1; index < points.length; index += 1) {
const start = points[index - 1]
const end = points[index]
const dx = end.x - start.x
const dy = end.y - start.y
const length = Math.hypot(dx, dy)
if (length === 0) {
continue
}
segments.push({ start, unitX: dx / length, unitY: dy / length, length })
totalLength += length
}
if (segments.length === 0 || totalLength < CHEVRON_DEPTH * 2) {
return points[0] ? `M ${svgNumber(points[0].x)} ${svgNumber(points[0].y)}` : ''
}
const chevronCount = Math.min(
MAX_CHEVRONS_PER_PATH,
Math.max(1, Math.floor(totalLength / CHEVRON_SPACING))
)
const commands: string[] = []
let segmentIndex = 0
let segmentStartDistance = 0
for (let index = 0; index < chevronCount; index += 1) {
const distance = (totalLength * (index + 1)) / (chevronCount + 1)
while (
segmentIndex < segments.length - 1 &&
distance > segmentStartDistance + segments[segmentIndex].length
) {
segmentStartDistance += segments[segmentIndex].length
segmentIndex += 1
}
const segment = segments[segmentIndex]
const offset = distance - segmentStartDistance
const tipX = segment.start.x + segment.unitX * offset
const tipY = segment.start.y + segment.unitY * offset
const backX = tipX - segment.unitX * CHEVRON_DEPTH
const backY = tipY - segment.unitY * CHEVRON_DEPTH
const perpendicularX = -segment.unitY * CHEVRON_HALF_WIDTH
const perpendicularY = segment.unitX * CHEVRON_HALF_WIDTH
commands.push(
`M ${svgNumber(backX + perpendicularX)} ${svgNumber(backY + perpendicularY)} L ${svgNumber(tipX)} ${svgNumber(tipY)} L ${svgNumber(backX - perpendicularX)} ${svgNumber(backY - perpendicularY)}`
)
}
return commands.join(' ')
}
export function agentMapDirectLineageChevronPath(
parent: AgentMapLineageNode,
child: AgentMapLineageNode
): string {
const dx = child.x - parent.x
const dy = child.y - parent.y
const distance = Math.hypot(dx, dy)
if (distance <= parent.radius + child.radius) {
return agentMapLineageChevronPath([parent])
}
const unitX = dx / distance
const unitY = dy / distance
return agentMapLineageChevronPath([
{ x: parent.x + unitX * parent.radius, y: parent.y + unitY * parent.radius },
{ x: child.x - unitX * child.radius, y: child.y - unitY * child.radius }
])
}
@@ -198,7 +198,6 @@
fill: none;
pointer-events: none;
stroke: color-mix(in srgb, var(--muted-foreground) 42%, transparent);
stroke-dasharray: 1 4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1;