From 0fa3a5bd1dda5606a059d121e694b0c7b58fa787 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:16:21 -0700 Subject: [PATCH] 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 --- .../AgentDashboardToolbar.tsx | 2 +- .../dashboard-popout/AgentMap.test.tsx | 2 + .../dashboard-popout/AgentMapScene.tsx | 11 +-- .../AgentMapWorktreeRingNode.tsx | 10 +- .../agent-map-lineage-chevron-path.test.ts | 46 ++++++++++ .../agent-map-lineage-chevron-path.ts | 92 +++++++++++++++++++ .../components/dashboard-popout/agent-map.css | 1 - 7 files changed, 145 insertions(+), 19 deletions(-) create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.ts diff --git a/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx b/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx index 53fa18a7398..016b96bad94 100644 --- a/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx @@ -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 diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx index 60f587bc907..5b1d4a0da6d 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx @@ -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', () => { diff --git a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx index 0dc09b6f0dd..251e7181802 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx @@ -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. */ diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx index 72d5875c801..9642cf16155 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -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 { diff --git a/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts new file mode 100644 index 00000000000..193651eaf28 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.ts b/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.ts new file mode 100644 index 00000000000..f1602805b2b --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.ts @@ -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 } + ]) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map.css b/src/renderer/src/components/dashboard-popout/agent-map.css index 12e2ad578a2..394c28b715b 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map.css +++ b/src/renderer/src/components/dashboard-popout/agent-map.css @@ -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;