From d3f5f5e73cd26c44c5b27295352d852904c30d09 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:55:09 -0700 Subject: [PATCH] Show directional lineage in agent map --- .../AgentDashboardToolbar.tsx | 2 +- .../dashboard-popout/AgentMap.test.tsx | 2 + .../dashboard-popout/AgentMapScene.tsx | 6 +- .../AgentMapWorktreeRingNode.tsx | 8 ++- .../agent-map-lineage-chevron-path.ts | 66 +++++++++++++++++++ .../components/dashboard-popout/agent-map.css | 1 - 6 files changed, 81 insertions(+), 4 deletions(-) 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..9831f77a302 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 { 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. */ diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx index 72d5875c801..090fbf7f6fb 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 { 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 { 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..ef4fa890636 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.ts @@ -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(' ') +} 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;