mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(agent-map): draw every visible orchestration edge, and let the filter hide them
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.
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
<div className={cn('flex min-h-0 flex-1', dialogCard && 'flex-row-reverse')}>
|
||||
@@ -107,6 +110,7 @@ export function AgentDashboardMapView({
|
||||
compact={dialogCard !== null}
|
||||
selectedPaneKey={dialogCard?.paneKey}
|
||||
enabledStates={agentStates}
|
||||
showOrchestrationLinks={showOrchestrationLinks}
|
||||
launchableAgentsByWorktreeId={snapshot.launchableAgentsByWorktreeId}
|
||||
workspaceContextMenusEnabled={workspaceContextMenusEnabled}
|
||||
onWorkspaceContextMenuOpenChange={onWorkspaceContextMenuOpenChange}
|
||||
|
||||
@@ -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<HTMLInputElement | null>
|
||||
}
|
||||
|
||||
@@ -157,10 +162,6 @@ function reviewStateLabel(state: DashboardReviewFilter): string {
|
||||
}
|
||||
}
|
||||
|
||||
function OptionCount({ count }: { count: number }): React.JSX.Element {
|
||||
return <span className="ml-auto text-[11px] tabular-nums text-muted-foreground">{count}</span>
|
||||
}
|
||||
|
||||
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({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64" sideOffset={6}>
|
||||
{showAgentlessWorkspaces !== undefined && onShowAgentlessWorkspacesChange ? (
|
||||
<>
|
||||
<DropdownMenuLabel>
|
||||
{translate('dashboardPopout.map.filters.workspaceVisibility', 'Map content')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showAgentlessWorkspaces}
|
||||
onCheckedChange={(checked) => onShowAgentlessWorkspacesChange(checked === true)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
<span className="truncate">
|
||||
{translate(
|
||||
'dashboardPopout.map.filters.agentlessWorkspaces',
|
||||
'Workspaces without agents'
|
||||
)}
|
||||
</span>
|
||||
<OptionCount count={agentlessWorkspaceCount} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
{showAgentlessWorkspaces !== undefined &&
|
||||
onShowAgentlessWorkspacesChange &&
|
||||
onShowOrchestrationLinksChange ? (
|
||||
<AgentMapContentFilterItems
|
||||
showAgentlessWorkspaces={showAgentlessWorkspaces}
|
||||
agentlessWorkspaceCount={agentlessWorkspaceCount}
|
||||
onShowAgentlessWorkspacesChange={onShowAgentlessWorkspacesChange}
|
||||
showOrchestrationLinks={showOrchestrationLinks !== false}
|
||||
onShowOrchestrationLinksChange={onShowOrchestrationLinksChange}
|
||||
/>
|
||||
) : null}
|
||||
{agentStates && agentStateCounts ? (
|
||||
<>
|
||||
@@ -309,7 +305,7 @@ export function AgentDashboardToolbar({
|
||||
>
|
||||
<AgentStateDot state={dotState} size="md" />
|
||||
<span className="truncate">{agentStateLabel(state)}</span>
|
||||
<OptionCount count={agentStateCounts[state]} />
|
||||
<FilterOptionCount count={agentStateCounts[state]} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
@@ -326,7 +322,7 @@ export function AgentDashboardToolbar({
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
<span className="truncate">{option.label}</span>
|
||||
<OptionCount count={option.count} />
|
||||
<FilterOptionCount count={option.count} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
@@ -348,7 +344,7 @@ export function AgentDashboardToolbar({
|
||||
>
|
||||
<span className={cn('size-2 rounded-full', meta.swatch)} />
|
||||
<span className="truncate">{option.label}</span>
|
||||
<OptionCount count={option.count} />
|
||||
<FilterOptionCount count={option.count} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
@@ -364,7 +360,7 @@ export function AgentDashboardToolbar({
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
<span>{reviewStateLabel(option)}</span>
|
||||
<OptionCount count={reviewCounts.get(option) ?? 0} />
|
||||
<FilterOptionCount count={reviewCounts.get(option) ?? 0} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -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<AgentMapState>
|
||||
showOrchestrationLinks?: boolean
|
||||
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
|
||||
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' }),
|
||||
|
||||
@@ -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<AgentMapState>
|
||||
/** Owned by the board's filter menu. Defaults to shown. */
|
||||
showOrchestrationLinks?: boolean
|
||||
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
|
||||
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}
|
||||
|
||||
@@ -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<string, RepoIcon | null>
|
||||
selectedPaneKey: string | null
|
||||
allowAggregation: boolean
|
||||
showOrchestrationLinks: boolean
|
||||
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
|
||||
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<AgentMapCanvasHandle, AgentMapCanvasProps>(
|
||||
function AgentMapCanvas(
|
||||
{
|
||||
@@ -84,6 +61,7 @@ export const AgentMapCanvas = forwardRef<AgentMapCanvasHandle, AgentMapCanvasPro
|
||||
repoIconsByRepoId,
|
||||
selectedPaneKey,
|
||||
allowAggregation,
|
||||
showOrchestrationLinks,
|
||||
launchableAgentsByWorktreeId,
|
||||
workspaceContextMenusEnabled = false,
|
||||
onWorkspaceContextMenuOpenChange,
|
||||
@@ -391,6 +369,7 @@ export const AgentMapCanvas = forwardRef<AgentMapCanvasHandle, AgentMapCanvasPro
|
||||
mapScale={mapScale}
|
||||
selectedPaneKey={selectedPaneKey}
|
||||
allowAggregation={allowAggregation}
|
||||
showOrchestrationLinks={showOrchestrationLinks}
|
||||
launchableAgentsByWorktreeId={launchableAgentsByWorktreeId}
|
||||
nodeRefs={nodeRefs}
|
||||
onSelectAgent={onSelectAgent}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { FilterOptionCount } from './FilterOptionCount'
|
||||
|
||||
type AgentMapContentFilterItemsProps = {
|
||||
showAgentlessWorkspaces: boolean
|
||||
agentlessWorkspaceCount: number
|
||||
onShowAgentlessWorkspacesChange: (show: boolean) => 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 (
|
||||
<>
|
||||
<DropdownMenuLabel>
|
||||
{translate('dashboardPopout.map.filters.workspaceVisibility', 'Map content')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showAgentlessWorkspaces}
|
||||
onCheckedChange={(checked) => onShowAgentlessWorkspacesChange(checked === true)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
<span className="truncate">
|
||||
{translate(
|
||||
'dashboardPopout.map.filters.agentlessWorkspaces',
|
||||
'Workspaces without agents'
|
||||
)}
|
||||
</span>
|
||||
<FilterOptionCount count={agentlessWorkspaceCount} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showOrchestrationLinks}
|
||||
onCheckedChange={(checked) => onShowOrchestrationLinksChange(checked === true)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
<span className="truncate">
|
||||
{translate('dashboardPopout.map.filters.orchestrationLinks', 'Orchestration links')}
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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()}
|
||||
|
||||
@@ -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<string, TuiAgent[]>
|
||||
nodeRefs: MutableRefObject<Map<string, SVGGElement>>
|
||||
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 (
|
||||
<g key={project.id}>
|
||||
<circle
|
||||
@@ -159,21 +163,18 @@ export const AgentMapScene = memo(function AgentMapScene({
|
||||
})}
|
||||
</g>
|
||||
<g className="agent-map-lineage-links" aria-hidden>
|
||||
{crossWorktreeLineage.map(({ parent, child }) => {
|
||||
const relation = parent.card.parentPaneKey ? 'subagent' : 'orchestration'
|
||||
return (
|
||||
<path
|
||||
key={child.card.paneKey}
|
||||
className={`agent-map-lineage-link is-cross-worktree${relation === 'subagent' ? ' is-subagent' : ''}`}
|
||||
data-agent-map-lineage-link=""
|
||||
data-agent-map-cross-worktree-lineage-link=""
|
||||
data-agent-map-lineage-relation={relation}
|
||||
data-parent-pane-key={parent.card.paneKey}
|
||||
data-child-pane-key={child.card.paneKey}
|
||||
d={agentLineagePath(parent, child)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{crossWorktreeLineage.map(({ parent, child }) => (
|
||||
<path
|
||||
key={child.card.paneKey}
|
||||
className="agent-map-lineage-link is-cross-worktree"
|
||||
data-agent-map-lineage-link=""
|
||||
data-agent-map-cross-worktree-lineage-link=""
|
||||
data-agent-map-lineage-relation={AGENT_MAP_LINEAGE_RELATION}
|
||||
data-parent-pane-key={parent.card.paneKey}
|
||||
data-child-pane-key={child.card.paneKey}
|
||||
d={agentLineagePath(parent, child)}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
{project.worktrees.map((worktree) => (
|
||||
<AgentMapWorktreeRingNode
|
||||
@@ -184,6 +185,7 @@ export const AgentMapScene = memo(function AgentMapScene({
|
||||
mapScale={mapScale}
|
||||
selectedPaneKey={selectedPaneKey}
|
||||
allowAggregation={allowAggregation}
|
||||
showOrchestrationLinks={showOrchestrationLinks}
|
||||
launchableAgents={launchableAgentsByWorktreeId?.[worktree.worktreeId]}
|
||||
nodeRefs={nodeRefs}
|
||||
onSelectAgent={onSelectAgent}
|
||||
|
||||
@@ -13,7 +13,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 { agentMapWorktreeActiveStatus } from './agent-map-worktree-active-status'
|
||||
|
||||
type AgentMapWorktreeRingNodeProps = {
|
||||
@@ -23,6 +23,7 @@ type AgentMapWorktreeRingNodeProps = {
|
||||
mapScale: number
|
||||
selectedPaneKey: string | null
|
||||
allowAggregation: boolean
|
||||
showOrchestrationLinks: boolean
|
||||
launchableAgents?: readonly TuiAgent[]
|
||||
nodeRefs: MutableRefObject<Map<string, SVGGElement>>
|
||||
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({
|
||||
) : (
|
||||
<>
|
||||
<g className="agent-map-lineage-links" aria-hidden>
|
||||
{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 (
|
||||
<path
|
||||
key={child.card.paneKey}
|
||||
className={`agent-map-lineage-link${relation === 'subagent' ? ' is-subagent' : ''}`}
|
||||
className="agent-map-lineage-link"
|
||||
data-agent-map-lineage-link=""
|
||||
data-agent-map-lineage-relation={relation}
|
||||
data-agent-map-lineage-relation={AGENT_MAP_LINEAGE_RELATION}
|
||||
data-parent-pane-key={parent.card.paneKey}
|
||||
data-child-pane-key={child.card.paneKey}
|
||||
d={lineagePath(parent, child)}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Right-aligned tally on a filter menu row. */
|
||||
export function FilterOptionCount({ count }: { count: number }): React.JSX.Element {
|
||||
return <span className="ml-auto text-[11px] tabular-nums text-muted-foreground">{count}</span>
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14977,6 +14977,7 @@
|
||||
"showStates": "Agent states",
|
||||
"canvasSummary": "{{shown}} of {{total}} agents shown",
|
||||
"agentlessWorkspaces": "Workspaces without agents",
|
||||
"orchestrationLinks": "Orchestration links",
|
||||
"workspaceVisibility": "Map content"
|
||||
},
|
||||
"liveContainmentMap": "Live containment map",
|
||||
|
||||
Reference in New Issue
Block a user