Show directional lineage in agent map

This commit is contained in:
Brennan Benson
2026-08-09 22:55:09 -07:00
parent 75650936f4
commit d3f5f5e73c
6 changed files with 81 additions and 4 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 { agentMapLineageChevronPath } from './agent-map-lineage-chevron-path'
import { AgentMapWorktreeLabel } from './AgentMapWorktreeLabel'
import { AgentMapWorktreeRingNode } from './AgentMapWorktreeRingNode'
@@ -60,7 +61,10 @@ function agentLineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode):
}
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 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 }
])
}
/** 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 { agentMapLineageChevronPath } from './agent-map-lineage-chevron-path'
import { agentMapWorktreeActiveStatus } from './agent-map-worktree-active-status'
type AgentMapWorktreeRingNodeProps = {
@@ -58,7 +59,12 @@ function lineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): strin
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 agentMapLineageChevronPath([
{ x: parent.x, y: startY },
{ x: parent.x, y: branchY },
{ x: child.x, y: branchY },
{ x: child.x, y: endY }
])
}
function agentName(card: DashboardCard): string {
@@ -0,0 +1,66 @@
export type AgentMapLineagePoint = {
x: number
y: 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
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) {
return points[0] ? `M ${svgNumber(points[0].x)} ${svgNumber(points[0].y)}` : ''
}
const chevronCount = 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(' ')
}
@@ -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;