From 360c496b9b56f6b7ced8657e59cc5188d531c9b5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:36:22 -0700 Subject: [PATCH] fix(agent-map): draw every visible orchestration edge, and let the filter hide them (#13087) The agent map dropped real parent -> child dispatch edges and mislabeled the ones it kept. - Remove the `child.y <= parent.y` gate on agent lineage. Both endpoints are drawn nodes, so the relationship is real whatever the layout ranked them; the gate silently hid edges that packing pressure placed side by side. `lineagePath` is now direction-aware so an upward edge does not exit the wrong side and draw back through both nodes. - Fix the relation attribute. `parent.card.parentPaneKey ? 'subagent' : ...` asked whether the PARENT has a parent and said nothing about the child, so a 3-deep chain rendered solid as if it were an in-process subagent. Every map node is a top-level pane agent -- build-dashboard-snapshot folds subagent rows into the parent card's roster and never makes them cards -- so every card-to-card edge is an orchestration edge. Dead CSS removed. - Add a "Orchestration links" toggle under Filter > Map content, default on, hiding both same-worktree and cross-worktree edges while keeping the nodes. Lineage still comes from the existing active-or-recently-settled dispatch context; making it historical would be a main-process change and is out of scope here by design. Extractions are max-lines pressure, not drive-by refactors: the toolbar hit 404/400 and AgentMapCanvas 403/400, and the repo forbids new max-lines suppressions. --- .../AgentDashboardMapView.tsx | 4 + .../AgentDashboardToolbar.tsx | 54 ++++++------ .../dashboard-popout/AgentMap.test.tsx | 83 ++++++++++++++++--- .../components/dashboard-popout/AgentMap.tsx | 4 + .../dashboard-popout/AgentMapCanvas.tsx | 31 ++----- .../AgentMapContentFilterItems.tsx | 59 +++++++++++++ .../AgentMapProjectLabel.test.tsx | 1 + .../dashboard-popout/AgentMapScene.tsx | 56 +++++++------ .../AgentMapWorktreeRingNode.tsx | 28 +++++-- .../dashboard-popout/FilterOptionCount.tsx | 4 + .../dashboard-popout/agent-map-canvas-zoom.ts | 25 ++++++ .../dashboard-popout/agent-map-layout.ts | 8 ++ .../components/dashboard-popout/agent-map.css | 4 - src/renderer/src/i18n/locales/en.json | 1 + 14 files changed, 257 insertions(+), 105 deletions(-) create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapContentFilterItems.tsx create mode 100644 src/renderer/src/components/dashboard-popout/FilterOptionCount.tsx create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-canvas-zoom.ts diff --git a/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx b/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx index 4010b5a0c30..5445a40ee47 100644 --- a/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx @@ -62,6 +62,7 @@ export function AgentDashboardMapView({ }: AgentDashboardMapViewProps): React.JSX.Element { const { agentStates, toggleAgentState, resetAgentStates } = useAgentMapStateFilter() const [showAgentlessWorkspaces, setShowAgentlessWorkspaces] = useState(false) + const [showOrchestrationLinks, setShowOrchestrationLinks] = useState(true) const agentlessWorkspaces = useMemo( () => selectAgentlessMapWorkspaces({ @@ -94,6 +95,8 @@ export function AgentDashboardMapView({ showAgentlessWorkspaces={showAgentlessWorkspaces} agentlessWorkspaceCount={agentlessWorkspaces.length} onShowAgentlessWorkspacesChange={setShowAgentlessWorkspaces} + showOrchestrationLinks={showOrchestrationLinks} + onShowOrchestrationLinksChange={setShowOrchestrationLinks} searchInputRef={searchInputRef} />
@@ -107,6 +110,7 @@ export function AgentDashboardMapView({ compact={dialogCard !== null} selectedPaneKey={dialogCard?.paneKey} enabledStates={agentStates} + showOrchestrationLinks={showOrchestrationLinks} launchableAgentsByWorktreeId={snapshot.launchableAgentsByWorktreeId} workspaceContextMenusEnabled={workspaceContextMenusEnabled} onWorkspaceContextMenuOpenChange={onWorkspaceContextMenuOpenChange} diff --git a/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx b/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx index ab4e1beed6c..53fa18a7398 100644 --- a/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx @@ -27,6 +27,8 @@ import { } from './agent-board-filtering' import { countAgentMapCards, type AgentMapState } from './agent-map-filter' import { AgentDashboardFilterChips } from './AgentDashboardFilterChips' +import { AgentMapContentFilterItems } from './AgentMapContentFilterItems' +import { FilterOptionCount } from './FilterOptionCount' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' type FilterOption = { id: string; label: string; count: number; color?: string } @@ -46,6 +48,9 @@ type AgentDashboardToolbarProps = { showAgentlessWorkspaces?: boolean agentlessWorkspaceCount?: number onShowAgentlessWorkspacesChange?: (show: boolean) => void + /** Map-only: the dashed parent→child dispatch edges. */ + showOrchestrationLinks?: boolean + onShowOrchestrationLinksChange?: (show: boolean) => void searchInputRef: React.RefObject } @@ -157,10 +162,6 @@ function reviewStateLabel(state: DashboardReviewFilter): string { } } -function OptionCount({ count }: { count: number }): React.JSX.Element { - return {count} -} - export function AgentDashboardToolbar({ cards, filterOptions, @@ -175,6 +176,8 @@ export function AgentDashboardToolbar({ showAgentlessWorkspaces, agentlessWorkspaceCount = 0, onShowAgentlessWorkspacesChange, + showOrchestrationLinks, + onShowOrchestrationLinksChange, searchInputRef }: AgentDashboardToolbarProps): React.JSX.Element { const isMac = navigator.userAgent.includes('Mac') @@ -190,7 +193,9 @@ export function AgentDashboardToolbar({ const activeCount = activeDashboardFilterCount(filters) + mutedStateCount + - (showAgentlessWorkspaces === true ? 1 : 0) + (showAgentlessWorkspaces === true ? 1 : 0) + + // Links show by default, so only hiding them is a deviation worth badging. + (showOrchestrationLinks === false ? 1 : 0) const toggleProject = (id: string): void => onFiltersChange({ ...filters, projects: toggleDashboardFilter(filters.projects, id) }) const toggleStatus = (id: string): void => @@ -207,6 +212,7 @@ export function AgentDashboardToolbar({ onFiltersChange({ projects: [], workspaceStatuses: [], reviewStates: [] }) onAgentStatesReset?.() onShowAgentlessWorkspacesChange?.(false) + onShowOrchestrationLinksChange?.(true) } const reviewLabel = (id: DashboardReviewFilter): string => translate('dashboardPopout.filters.reviewChip', 'Review: {{state}}', { @@ -274,26 +280,16 @@ export function AgentDashboardToolbar({ - {showAgentlessWorkspaces !== undefined && onShowAgentlessWorkspacesChange ? ( - <> - - {translate('dashboardPopout.map.filters.workspaceVisibility', 'Map content')} - - onShowAgentlessWorkspacesChange(checked === true)} - onSelect={(event) => event.preventDefault()} - > - - {translate( - 'dashboardPopout.map.filters.agentlessWorkspaces', - 'Workspaces without agents' - )} - - - - - + {showAgentlessWorkspaces !== undefined && + onShowAgentlessWorkspacesChange && + onShowOrchestrationLinksChange ? ( + ) : null} {agentStates && agentStateCounts ? ( <> @@ -309,7 +305,7 @@ export function AgentDashboardToolbar({ > {agentStateLabel(state)} - + ))} @@ -326,7 +322,7 @@ export function AgentDashboardToolbar({ onSelect={(event) => event.preventDefault()} > {option.label} - + ))} @@ -348,7 +344,7 @@ export function AgentDashboardToolbar({ > {option.label} - + ) })} @@ -364,7 +360,7 @@ export function AgentDashboardToolbar({ onSelect={(event) => event.preventDefault()} > {reviewStateLabel(option)} - + ))} diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx index 483e9d1e9ed..60f587bc907 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx @@ -49,6 +49,7 @@ function renderMap( compact = false, workspaceContextMenusEnabled = false, enabledStates, + showOrchestrationLinks, launchableAgentsByWorktreeId, onSpawnAgent, onSleepWorkspace @@ -58,6 +59,7 @@ function renderMap( compact?: boolean workspaceContextMenusEnabled?: boolean enabledStates?: ReadonlySet + showOrchestrationLinks?: boolean launchableAgentsByWorktreeId?: Record onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void @@ -72,6 +74,7 @@ function renderMap( compact={compact} workspaceContextMenusEnabled={workspaceContextMenusEnabled} enabledStates={enabledStates} + showOrchestrationLinks={showOrchestrationLinks} launchableAgentsByWorktreeId={launchableAgentsByWorktreeId} onSpawnAgent={onSpawnAgent} onSleepWorkspace={onSleepWorkspace} @@ -350,18 +353,19 @@ describe('AgentMap', () => { }) const { container } = renderMap([parent, child, nested, orphan]) const workerLink = container.querySelector('[data-child-pane-key="child"]') - const subagentLink = container.querySelector('[data-child-pane-key="nested"]') + const nestedLink = container.querySelector('[data-child-pane-key="nested"]') expect(screen.getByRole('button', { name: /Coordinator/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: /^Worker/ })).toBeInTheDocument() expect(container.querySelectorAll('[data-agent-map-lineage-link]')).toHaveLength(2) expect(workerLink).toHaveClass('agent-map-lineage-link') - expect(workerLink).not.toHaveClass('is-subagent') expect(workerLink).toHaveAttribute('data-agent-map-lineage-relation', 'orchestration') expect(workerLink).toHaveAttribute('data-parent-pane-key', 'parent') - expect(subagentLink).toHaveClass('agent-map-lineage-link', 'is-subagent') - expect(subagentLink).toHaveAttribute('data-agent-map-lineage-relation', 'subagent') - expect(subagentLink).toHaveAttribute('data-parent-pane-key', 'child') + // 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') }) it('connects spawned workers across worktree rings', () => { @@ -387,13 +391,12 @@ describe('AgentMap', () => { }) const { container } = renderMap([parent, child, nested]) const workerLink = container.querySelector('[data-child-pane-key="child"]') - const subagentLink = container.querySelector('[data-child-pane-key="nested"]') + const nestedLink = container.querySelector('[data-child-pane-key="nested"]') expect(workerLink).toHaveClass('agent-map-lineage-link', 'is-cross-worktree') - expect(workerLink).not.toHaveClass('is-subagent') expect(workerLink).toHaveAttribute('data-agent-map-lineage-relation', 'orchestration') - expect(subagentLink).toHaveClass('agent-map-lineage-link', 'is-cross-worktree', 'is-subagent') - expect(subagentLink).toHaveAttribute('data-agent-map-lineage-relation', 'subagent') + expect(nestedLink).toHaveClass('agent-map-lineage-link', 'is-cross-worktree') + expect(nestedLink).toHaveAttribute('data-agent-map-lineage-relation', 'orchestration') }) it('keeps lineage styling to one lightweight path per relationship at fleet scale', () => { @@ -409,13 +412,71 @@ describe('AgentMap', () => { expect(container.querySelectorAll('[data-agent-map-lineage-link]')).toHaveLength(239) expect( container.querySelectorAll('[data-agent-map-lineage-relation="orchestration"]') - ).toHaveLength(1) + ).toHaveLength(239) expect(container.querySelectorAll('[data-agent-map-lineage-relation="subagent"]')).toHaveLength( - 238 + 0 ) expect(container.querySelectorAll('filter, animate, animateTransform')).toHaveLength(0) }) + it('hides orchestration links when the filter turns them off, keeping the agents', () => { + const sameWorktree = [ + card({ paneKey: 'parent', conversationName: 'Coordinator' }), + card({ paneKey: 'child', parentPaneKey: 'parent', conversationName: 'Worker' }) + ] + const crossWorktree = [ + card({ paneKey: 'far-parent', worktreeId: 'wt-a', worktreeName: 'A' }), + card({ + paneKey: 'far-child', + worktreeId: 'wt-b', + worktreeName: 'B', + parentPaneKey: 'far-parent' + }) + ] + const cards = [...sameWorktree, ...crossWorktree] + + const shown = renderMap(cards) + expect(shown.container.querySelectorAll('[data-agent-map-lineage-link]')).toHaveLength(2) + cleanup() + + const hidden = renderMap(cards, { showOrchestrationLinks: false }) + // Both same-worktree and cross-worktree edges go; the nodes themselves stay. + expect(hidden.container.querySelectorAll('[data-agent-map-lineage-link]')).toHaveLength(0) + expect(screen.getByRole('button', { name: /Coordinator/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^Worker/ })).toBeInTheDocument() + }) + + it('draws no lineage edge for an agent that lists itself as its own parent', () => { + const { container } = renderMap([ + card({ paneKey: 'self', parentPaneKey: 'self', conversationName: 'Self parent' }), + card({ paneKey: 'other', conversationName: 'Unrelated' }) + ]) + + expect(container.querySelectorAll('[data-agent-map-lineage-link]')).toHaveLength(0) + expect(screen.getByRole('button', { name: /Self parent/ })).toBeInTheDocument() + }) + + it('connects lineage between visible nodes even when the child is not ranked below its parent', () => { + // A 2-cycle cannot be ranked consistently, so the bounded layout (>256 agents) + // must place one of its edges pointing upward. Both nodes are drawn, so both + // edges must be too — the old y-ordering gate silently dropped the upward one. + const cards = [ + card({ paneKey: 'cycle-a', parentPaneKey: 'cycle-b', conversationName: 'Cycle A' }), + card({ paneKey: 'cycle-b', parentPaneKey: 'cycle-a', conversationName: 'Cycle B' }), + ...Array.from({ length: 255 }, (_, index) => + card({ + paneKey: `filler-${index}`, + parentPaneKey: index === 0 ? undefined : `filler-${index - 1}`, + conversationName: `Filler ${index}` + }) + ) + ] + const { container } = renderMap(cards, { selectedPaneKey: 'cycle-a' }) + + expect(container.querySelector('[data-child-pane-key="cycle-a"]')).not.toBeNull() + expect(container.querySelector('[data-child-pane-key="cycle-b"]')).not.toBeNull() + }) + it('connects visible child worktrees beneath their parent ring', () => { const { container } = renderMap([ card({ paneKey: 'parent', worktreeId: 'parent-worktree', worktreeName: 'Parent' }), diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.tsx index 4e9c3c413ca..6bce95eaeac 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.tsx @@ -31,6 +31,8 @@ type AgentMapProps = { /** Owned by the board so the shared toolbar filter can drive it. Defaults to * every state, i.e. the map shows whatever it is handed. */ enabledStates?: ReadonlySet + /** Owned by the board's filter menu. Defaults to shown. */ + showOrchestrationLinks?: boolean launchableAgentsByWorktreeId?: Record workspaceContextMenusEnabled?: boolean onWorkspaceContextMenuOpenChange?: (open: boolean) => void @@ -72,6 +74,7 @@ export function AgentMap({ compact = false, selectedPaneKey = null, enabledStates = ALL_AGENT_STATES, + showOrchestrationLinks = true, launchableAgentsByWorktreeId, workspaceContextMenusEnabled = false, onWorkspaceContextMenuOpenChange, @@ -169,6 +172,7 @@ export function AgentMap({ repoIconsByRepoId={repoIconsByRepoId} selectedPaneKey={selectedPaneKey} allowAggregation + showOrchestrationLinks={showOrchestrationLinks} launchableAgentsByWorktreeId={launchableAgentsByWorktreeId} workspaceContextMenusEnabled={workspaceContextMenusEnabled} onWorkspaceContextMenuOpenChange={onWorkspaceContextMenuOpenChange} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx index 33cd767cc6a..06e52c84453 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx @@ -16,13 +16,9 @@ import type { } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' import type { TuiAgent } from '../../../../shared/types' -import { - AGENT_MAP_AGENT_RADIUS, - type AgentMapAgentNode, - type AgentMapProjectRing, - type AgentMapLayout -} from './agent-map-layout' +import type { AgentMapAgentNode, AgentMapProjectRing, AgentMapLayout } from './agent-map-layout' import { AgentMapScene } from './AgentMapScene' +import { agentFocusZoom, clamp, MAX_ZOOM, MIN_ZOOM } from './agent-map-canvas-zoom' import { AgentMapViewportControls } from './AgentMapViewportControls' import { agentMapAgents, @@ -35,10 +31,7 @@ import { useAgentMapCanvasSize } from './useAgentMapCanvasSize' import { useAgentMapSelectedFocus } from './useAgentMapSelectedFocus' import { useAgentMapViewportTransition } from './useAgentMapViewportTransition' -const MIN_ZOOM = 0.7 -const MAX_ZOOM = 24 const AGENT_FOCUS_DURATION_MS = 240 -const AGENT_FOCUS_RADIUS_PX = 24 type Point = { x: number; y: number } @@ -52,6 +45,7 @@ type AgentMapCanvasProps = { repoIconsByRepoId?: Record selectedPaneKey: string | null allowAggregation: boolean + showOrchestrationLinks: boolean launchableAgentsByWorktreeId?: Record workspaceContextMenusEnabled?: boolean onWorkspaceContextMenuOpenChange?: (open: boolean) => void @@ -60,23 +54,6 @@ type AgentMapCanvasProps = { onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void } -function clamp(value: number, minimum: number, maximum: number): number { - return Math.max(minimum, Math.min(maximum, value)) -} - -function agentFocusZoom(layout: AgentMapLayout, width: number, height: number): number { - const aspect = width / Math.max(1, height) - const baseWidth = Math.max(layout.width, layout.height * aspect) - return clamp( - Math.max( - 2, - (baseWidth * AGENT_FOCUS_RADIUS_PX) / (Math.max(1, width) * AGENT_MAP_AGENT_RADIUS) - ), - MIN_ZOOM, - MAX_ZOOM - ) -} - export const AgentMapCanvas = forwardRef( function AgentMapCanvas( { @@ -84,6 +61,7 @@ export const AgentMapCanvas = forwardRef void + showOrchestrationLinks: boolean + onShowOrchestrationLinksChange: (show: boolean) => void +} + +/** + * The map-only "Map content" rows of the shared dashboard filter menu. These + * govern what the map draws rather than which cards survive filtering, so they + * sit apart from the project/status/review sections. + */ +export function AgentMapContentFilterItems({ + showAgentlessWorkspaces, + agentlessWorkspaceCount, + onShowAgentlessWorkspacesChange, + showOrchestrationLinks, + onShowOrchestrationLinksChange +}: AgentMapContentFilterItemsProps): React.JSX.Element { + return ( + <> + + {translate('dashboardPopout.map.filters.workspaceVisibility', 'Map content')} + + onShowAgentlessWorkspacesChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + + {translate( + 'dashboardPopout.map.filters.agentlessWorkspaces', + 'Workspaces without agents' + )} + + + + onShowOrchestrationLinksChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + + {translate('dashboardPopout.map.filters.orchestrationLinks', 'Orchestration links')} + + + + + ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx index 5cbf41b7abb..7846e010c78 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx @@ -41,6 +41,7 @@ describe('AgentMapScene project labels', () => { mapScale={0.5} selectedPaneKey={null} allowAggregation + showOrchestrationLinks nodeRefs={{ current: new Map() }} onSelectAgent={vi.fn()} onAgentKeyDown={vi.fn()} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx index 2719f4f7921..0dc09b6f0dd 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx @@ -10,7 +10,7 @@ import type { AgentMapProjectRing, AgentMapWorktreeRing } from './agent-map-layout' -import { shouldAggregateAgentMapWorktree } from './agent-map-layout' +import { AGENT_MAP_LINEAGE_RELATION, shouldAggregateAgentMapWorktree } from './agent-map-layout' import { selectVisibleAgentMapLabels } from './agent-map-label-declutter' import { AgentMapWorktreeLabel } from './AgentMapWorktreeLabel' import { AgentMapWorktreeRingNode } from './AgentMapWorktreeRingNode' @@ -23,6 +23,7 @@ type AgentMapSceneProps = { mapScale: number selectedPaneKey: string | null allowAggregation: boolean + showOrchestrationLinks: boolean launchableAgentsByWorktreeId?: Record nodeRefs: MutableRefObject> onSelectAgent: (card: DashboardCard) => void @@ -71,6 +72,7 @@ export const AgentMapScene = memo(function AgentMapScene({ mapScale, selectedPaneKey, allowAggregation, + showOrchestrationLinks, launchableAgentsByWorktreeId, nodeRefs, onSelectAgent, @@ -114,17 +116,19 @@ export const AgentMapScene = memo(function AgentMapScene({ '{{agents}} agents · {{workspaces}} workspaces', { agents: project.agentCount, workspaces: project.worktrees.length } ).toUpperCase() - const crossWorktreeLineage = project.worktrees.flatMap((worktree) => - worktree.agents.flatMap((child) => { - const parent = child.card.parentPaneKey - ? visibleAgentsByPaneKey.get(child.card.parentPaneKey) - : undefined - const childLocation = visibleAgentsByPaneKey.get(child.card.paneKey) - return parent && childLocation && parent.worktreeId !== childLocation.worktreeId - ? [{ parent: parent.agent, child }] - : [] - }) - ) + const crossWorktreeLineage = !showOrchestrationLinks + ? [] + : project.worktrees.flatMap((worktree) => + worktree.agents.flatMap((child) => { + const parent = child.card.parentPaneKey + ? visibleAgentsByPaneKey.get(child.card.parentPaneKey) + : undefined + const childLocation = visibleAgentsByPaneKey.get(child.card.paneKey) + return parent && childLocation && parent.worktreeId !== childLocation.worktreeId + ? [{ parent: parent.agent, child }] + : [] + }) + ) return ( - {crossWorktreeLineage.map(({ parent, child }) => { - const relation = parent.card.parentPaneKey ? 'subagent' : 'orchestration' - return ( - - ) - })} + {crossWorktreeLineage.map(({ parent, child }) => ( + + ))} {project.worktrees.map((worktree) => ( > onSelectAgent: (card: DashboardCard) => void @@ -49,9 +50,13 @@ 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 startY = parent.y + parent.radius - const endY = child.y - child.radius + 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}` } @@ -178,6 +183,7 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ mapScale, selectedPaneKey, allowAggregation, + showOrchestrationLinks, launchableAgents, nodeRefs, onSelectAgent, @@ -268,20 +274,26 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ ) : ( <> - {worktree.agents.map((child) => { + {(showOrchestrationLinks ? worktree.agents : []).map((child) => { const parent = child.card.parentPaneKey ? agentsByPaneKey.get(child.card.parentPaneKey) : undefined - if (!parent || child.y <= parent.y) { + // Why no y-gate: both endpoints are drawn nodes, so the relationship is + // real whatever the layout ranked them. Suppressing the edge only hid + // lineage that packing pressure had placed side by side. + // + // Self-parents are dropped explicitly — the layout already ignores them + // (`layoutAgentMapLineage`), and the y-gate used to mask them here by + // accident since a node never ranks below itself. + if (!parent || parent.card.paneKey === child.card.paneKey) { return null } - const relation = parent.card.parentPaneKey ? 'subagent' : 'orchestration' return ( {count} +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-canvas-zoom.ts b/src/renderer/src/components/dashboard-popout/agent-map-canvas-zoom.ts new file mode 100644 index 00000000000..ab2d846a387 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-canvas-zoom.ts @@ -0,0 +1,25 @@ +import { AGENT_MAP_AGENT_RADIUS, type AgentMapLayout } from './agent-map-layout' + +export const MIN_ZOOM = 0.7 +export const MAX_ZOOM = 24 + +/** Screen radius a single agent should occupy once focused. */ +const AGENT_FOCUS_RADIUS_PX = 24 + +export function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)) +} + +/** Zoom that brings one agent up to `AGENT_FOCUS_RADIUS_PX` on screen. */ +export function agentFocusZoom(layout: AgentMapLayout, width: number, height: number): number { + const aspect = width / Math.max(1, height) + const baseWidth = Math.max(layout.width, layout.height * aspect) + return clamp( + Math.max( + 2, + (baseWidth * AGENT_FOCUS_RADIUS_PX) / (Math.max(1, width) * AGENT_MAP_AGENT_RADIUS) + ), + MIN_ZOOM, + MAX_ZOOM + ) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout.ts index 68602f95c04..b75b072643f 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-layout.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout.ts @@ -25,6 +25,14 @@ export const AGENT_MAP_AGENT_RADIUS = 20 export const AGENT_MAP_AGGREGATE_ZOOM = 1.15 export const AGENT_MAP_RING_HEADER_HEIGHT = 40 +/** + * Every map node is a top-level pane agent — in-process subagent rows are folded + * into their parent card's `subagents` roster and never become cards themselves + * (`build-dashboard-snapshot.ts`). So an edge between two nodes is always an + * orchestration dispatch, and there is no second relation to distinguish. + */ +export const AGENT_MAP_LINEAGE_RELATION = 'orchestration' + const PROJECT_PADDING = 12 const WORLD_MARGIN = 32 const RING_CONTENT_OFFSET = AGENT_MAP_RING_HEADER_HEIGHT / 2 diff --git a/src/renderer/src/components/dashboard-popout/agent-map.css b/src/renderer/src/components/dashboard-popout/agent-map.css index 1170931f1b8..12e2ad578a2 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map.css +++ b/src/renderer/src/components/dashboard-popout/agent-map.css @@ -205,10 +205,6 @@ vector-effect: non-scaling-stroke; } -.agent-map-lineage-link.is-subagent { - stroke-dasharray: none; -} - .agent-map-agent-hit { fill: transparent; stroke: transparent; diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 169c5cad344..9cdfd6ecc18 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15055,6 +15055,7 @@ "showStates": "Agent states", "canvasSummary": "{{shown}} of {{total}} agents shown", "agentlessWorkspaces": "Workspaces without agents", + "orchestrationLinks": "Orchestration links", "workspaceVisibility": "Map content" }, "liveContainmentMap": "Live containment map",