mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user