Remove agent map from dashboard popout

The agent map view was not functional and its components have been removed entirely. The dashboard popout now only supports the kanban board view, with all map-related code, utilities, types, and translations cleaned up accordingly.
This commit is contained in:
Jinjing
2026-09-15 21:36:00 -07:00
parent e6a41081a3
commit 1b6481d692
102 changed files with 31 additions and 11751 deletions
-1
View File
@@ -30,7 +30,6 @@
"error",
{
"allow": [
"agent-map-*",
"comment-md-*",
"compact-agent-*",
"feature-wall-*",
-26
View File
@@ -4367,16 +4367,6 @@
"src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx": 21,
"src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx": 469,
"src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx": 393,
"src/renderer/src/components/dashboard-popout/AgentMap.test.tsx": 1741,
"src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx": 75,
"src/renderer/src/components/dashboard-popout/AgentMapFilterPanel.test.tsx": 126,
"src/renderer/src/components/dashboard-popout/AgentMapMotion.test.tsx": 189,
"src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx": 106,
"src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx": 289,
"src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx": 732,
"src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.test.tsx": 1678,
"src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts": 7,
"src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx": 2066,
"src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx": 234,
"src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx": 1314,
"src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx": 149,
@@ -4384,21 +4374,6 @@
"src/renderer/src/components/dashboard-popout/DashboardHostBadge.test.tsx": 64,
"src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.test.tsx": 26,
"src/renderer/src/components/dashboard-popout/agent-board-filtering.test.ts": 8,
"src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts": 9,
"src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts": 12,
"src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts": 7,
"src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts": 12,
"src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts": 152,
"src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts": 26,
"src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts": 9,
"src/renderer/src/components/dashboard-popout/agent-map-node-metadata.test.ts": 10,
"src/renderer/src/components/dashboard-popout/agent-map-node-presentation.test.ts": 4,
"src/renderer/src/components/dashboard-popout/agent-map-quick-views.test.ts": 11,
"src/renderer/src/components/dashboard-popout/agent-map-time-filter.test.ts": 6,
"src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts": 5,
"src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts": 8,
"src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts": 161,
"src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts": 1600,
"src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts": 9,
"src/renderer/src/components/dashboard-popout/preview-grid-claim.test.ts": 18,
"src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts": 228,
@@ -4407,7 +4382,6 @@
"src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts": 13,
"src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts": 11,
"src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.test.ts": 10,
"src/renderer/src/components/dashboard-popout/useAgentMapFilters.test.tsx": 36,
"src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx": 59,
"src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx": 227,
"src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.test.tsx": 68,
+1 -10
View File
@@ -132,18 +132,9 @@ describe('registerDashboardPopoutHandlers', () => {
store.getSettings.mockReturnValue({ experimentalAgentDashboardPopout: true })
handlers.get('dashboardPopout:open')!({ sender: mainSender } as never)
expect(createPopoutMock).toHaveBeenCalledWith(store, undefined, {
expect(createPopoutMock).toHaveBeenCalledWith(store, {
getKeybindings: expect.any(Function)
})
handlers.get('dashboardPopout:open')!({ sender: mainSender } as never, 'map')
expect(createPopoutMock).toHaveBeenLastCalledWith(store, 'map', {
getKeybindings: expect.any(Function)
})
createPopoutMock.mockClear()
handlers.get('dashboardPopout:open')!({ sender: mainSender } as never, 'invalid')
expect(createPopoutMock).not.toHaveBeenCalled()
})
it('auto-closes the popout when the feature is disabled', () => {
+2 -5
View File
@@ -57,14 +57,11 @@ export function registerDashboardPopoutHandlers(
}
})
ipcMain.handle('dashboardPopout:open', (event, view: unknown): void => {
ipcMain.handle('dashboardPopout:open', (event): void => {
if (!isTrustedUIRenderer(event.sender) || !isDashboardEnabled(store)) {
return
}
if (view !== undefined && view !== 'board' && view !== 'map') {
return
}
createOrFocusDashboardPopout(store, view, {
createOrFocusDashboardPopout(store, {
getKeybindings: () => keybindings?.getOverrides()
})
})
+10 -24
View File
@@ -235,29 +235,23 @@ describe('createOrFocusDashboardPopout', () => {
expect(win.show).toHaveBeenCalledTimes(1)
})
it('loads the prod file entry with the requested view', () => {
createOrFocusDashboardPopout(makeStore() as never, 'kanban')
it('loads the prod file entry', () => {
createOrFocusDashboardPopout(makeStore() as never)
const win = instances[0]
expect(win.loadURL).not.toHaveBeenCalled()
expect(win.loadFile).toHaveBeenCalledTimes(1)
const [file, options] = win.loadFile.mock.calls[0]
expect(String(file)).toMatch(/renderer[\\/]popout\.html$/)
expect(options).toEqual({ search: 'view=kanban' })
expect(options).toBeUndefined()
})
it('opens on the current dashboard view by default', () => {
createOrFocusDashboardPopout(makeStore() as never)
expect(instances[0].loadFile.mock.calls[0][1]).toEqual({ search: 'view=board' })
})
it('loads the dev server URL with the requested view when in dev', () => {
it('loads the dev server URL when in dev', () => {
isMock.dev = true
vi.stubEnv('ELECTRON_RENDERER_URL', RENDERER_URL)
createOrFocusDashboardPopout(makeStore() as never, 'kanban')
createOrFocusDashboardPopout(makeStore() as never)
const win = instances[0]
expect(win.loadFile).not.toHaveBeenCalled()
expect(win.loadURL).toHaveBeenCalledWith(`${RENDERER_URL}/popout.html?view=kanban`)
expect(win.loadURL).toHaveBeenCalledWith(`${RENDERER_URL}/popout.html`)
})
it('focuses the existing window instead of creating a second one', () => {
@@ -269,16 +263,6 @@ describe('createOrFocusDashboardPopout', () => {
expect(instances[0].focus).toHaveBeenCalledTimes(1)
})
it('switches an existing popout to an explicitly requested view', () => {
const store = makeStore()
createOrFocusDashboardPopout(store as never)
const win = instances[0]
createOrFocusDashboardPopout(store as never, 'map')
expect(win.webContents.send).toHaveBeenCalledWith('dashboard:viewRequested', 'map')
})
it('trusts only the live popout webContents', () => {
const win = createOrFocusDashboardPopout(makeStore() as never) as unknown as FakeWindow
expect(isDashboardPopoutRenderer(win.webContents as never)).toBe(true)
@@ -446,12 +430,14 @@ describe('createOrFocusDashboardPopout', () => {
})
it('respects zoom keybinding overrides for keyboard and mouse-wheel paths', () => {
const win = createOrFocusDashboardPopout(makeStore() as never, undefined, {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: makeStore() is a partial store stub; this path only reads UI/getKeybindings, matching every other popout call here.
createOrFocusDashboardPopout(makeStore() as never, {
getKeybindings: () => ({
'zoom.in': ['Mod+Y'],
'zoom.out': []
})
}) as unknown as FakeWindow
})
const win = instances[0]
const mod =
process.platform === 'darwin'
? { meta: true, control: false }
+5 -13
View File
@@ -19,7 +19,6 @@ const MIN_WIDTH = 480
const MIN_HEIGHT = 360
const DEFAULT_WIDTH = 960
const DEFAULT_HEIGHT = 720
const DEFAULT_VIEW = 'board'
const DASHBOARD_POPOUT_PARTITION = 'orca-dashboard-popout'
// Why: singleton — the dashboard is a companion surface, so a second "Pop Out"
@@ -99,14 +98,13 @@ function broadcastPopoutOpenChanged(open: boolean): void {
}
}
function loadDashboardPopout(window: BrowserWindow, view: string): void {
const search = `view=${encodeURIComponent(view)}`
function loadDashboardPopout(window: BrowserWindow): void {
// Why: mirror loadMainWindow's dev/prod branch — the dev server serves the
// second HTML entry, prod loads the emitted file.
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
void window.loadURL(`${process.env.ELECTRON_RENDERER_URL}/popout.html?${search}`)
void window.loadURL(`${process.env.ELECTRON_RENDERER_URL}/popout.html`)
} else {
void window.loadFile(join(__dirname, '../renderer/popout.html'), { search })
void window.loadFile(join(__dirname, '../renderer/popout.html'))
}
}
@@ -135,11 +133,10 @@ function resolveRestoredBounds(store: Store | null): {
* Open the pop-out dashboard window, or focus it if already open. The window is
* a standalone top-level BrowserWindow with a native frame that reuses the same
* preload/window.api as the main window but renders its own React root
* (popout.html?view=…).
* (popout.html).
*/
export function createOrFocusDashboardPopout(
store: Store | null,
view?: string,
options: { getKeybindings?: () => KeybindingOverrides | undefined } = {}
): BrowserWindow {
if (dashboardPopoutWindow && !dashboardPopoutWindow.isDestroyed()) {
@@ -149,14 +146,9 @@ export function createOrFocusDashboardPopout(
if (!isBackgroundLaunch()) {
dashboardPopoutWindow.focus()
}
if (view) {
dashboardPopoutWindow.webContents.send('dashboard:viewRequested', view)
}
return dashboardPopoutWindow
}
const initialView = view ?? DEFAULT_VIEW
const savedBounds = resolveRestoredBounds(store)
const window = new BrowserWindow({
@@ -291,7 +283,7 @@ export function createOrFocusDashboardPopout(
broadcastPopoutOpenChanged(false)
})
loadDashboardPopout(window, initialView)
loadDashboardPopout(window)
return window
}
+1 -2
View File
@@ -10,7 +10,7 @@ import type {
} from '../../shared/terminal-preview'
export type DashboardApi = {
openPopout: (view?: 'board' | 'map') => Promise<void>
openPopout: () => Promise<void>
publishSnapshot: (snapshot: DashboardSnapshot) => Promise<void>
getPopoutOpen: () => Promise<boolean>
onPopoutOpenChanged: (callback: (open: boolean) => void) => () => void
@@ -21,7 +21,6 @@ export type DashboardApi = {
onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void) => () => void
requestSnapshot: () => Promise<void>
onSnapshot: (callback: (snapshot: DashboardSnapshot) => void) => () => void
onViewRequested: (callback: (view: 'board' | 'map') => void) => () => void
revealAgent: (args: DashboardRevealAgentArgs) => Promise<void>
ackAgent: (paneKey: string) => Promise<void>
spawnAgent: (args: DashboardSpawnAgentArgs) => Promise<void>
+1 -8
View File
@@ -9,8 +9,7 @@ import type { PreloadApi } from '../api-types'
export const dashboardApi = {
// Open the pop-out dashboard window, or focus it if already open.
openPopout: (view?: 'board' | 'map'): Promise<void> =>
ipcRenderer.invoke('dashboardPopout:open', view),
openPopout: (): Promise<void> => ipcRenderer.invoke('dashboardPopout:open'),
// ── Producer side (main window) ──────────────────────────────────────
publishSnapshot: (snapshot: DashboardSnapshot): Promise<void> =>
@@ -58,12 +57,6 @@ export const dashboardApi = {
ipcRenderer.on('dashboard:snapshot', listener)
return () => ipcRenderer.removeListener('dashboard:snapshot', listener)
},
onViewRequested: (callback: (view: 'board' | 'map') => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, view: 'board' | 'map'): void =>
callback(view)
ipcRenderer.on('dashboard:viewRequested', listener)
return () => ipcRenderer.removeListener('dashboard:viewRequested', listener)
},
revealAgent: (args: DashboardRevealAgentArgs): Promise<void> =>
ipcRenderer.invoke('dashboardPopout:revealAgent', args),
ackAgent: (paneKey: string): Promise<void> =>
+2 -2
View File
@@ -177,8 +177,8 @@
--terminal-pane-locate: var(--color-blue-600);
--ai-action-accent: var(--color-violet-500);
/* "An agent is asking you something" — one hue for every surface that shows
it (sidebar, tabs, dashboard, agent map). Orange, not amber: on the map it
has to stay separable from working-yellow at a glance. */
it (sidebar, tabs, dashboard, kanban). Orange, not amber: it has to stay
separable from working-yellow at a glance. */
--agent-question: var(--color-orange-600);
/* Legible weight for text/glyphs on a tinted --agent-question surface. */
--agent-question-text: var(--color-orange-700);
@@ -3,9 +3,8 @@ import { MessageCircleQuestion } from 'lucide-react'
import { cn } from '@/lib/utils'
// Why: "the agent is asking you something" shows up in the sidebar, terminal
// tabs, the dashboard, the kanban and the agent map. One icon + one token
// (--agent-question) so the four never drift apart; the map paints the same
// token from agent-map.css. Callers pass sizing via className.
// tabs, the dashboard and the kanban. One icon + one token (--agent-question)
// so they never drift apart. Callers pass sizing via className.
type AgentQuestionIconProps = React.ComponentProps<typeof MessageCircleQuestion>
@@ -1,188 +0,0 @@
import { Suspense, useMemo, useState, type RefObject } from 'react'
import type {
DashboardCard,
DashboardSleepWorkspaceArgs,
DashboardSnapshot,
DashboardSpawnAgentArgs
} from '../../../../shared/dashboard-snapshot'
import { cn } from '@/lib/utils'
import { lazyWithRetry } from '@/lib/lazy-with-retry'
import { AgentDashboardToolbar } from './AgentDashboardToolbar'
import { AgentTerminalPanel, type AgentRevealArgs } from './AgentTerminalDialog'
import {
EMPTY_DASHBOARD_FILTERS,
filterDashboardWorkspaces,
type DashboardFilters
} from './agent-board-filtering'
import { countAgentMapAgentTypes, filterAgentMapCards } from './agent-map-filter'
import { selectAgentlessMapWorkspaces } from './agent-map-workspace-visibility'
import { AgentMapFilterChips } from './AgentMapFilterChips'
import { AgentMapFilterPanel } from './AgentMapFilterPanel'
import { useAgentMapFilters } from './useAgentMapFilters'
const AgentMap = lazyWithRetry(
() => import('./AgentMap').then((module) => ({ default: module.AgentMap })),
{ reloadKey: 'agent-map' }
)
type AgentDashboardMapViewProps = {
snapshot: DashboardSnapshot
cards: DashboardCard[]
query: string
onQueryChange: (query: string) => void
filters: DashboardFilters
onFiltersChange: (filters: DashboardFilters) => void
searchInputRef: RefObject<HTMLInputElement | null>
now: number
dialogCard: DashboardCard | null
onDialogOpenChange: (open: boolean) => void
onRevealAgent: (args: AgentRevealArgs) => void
onOpenTerminal: (card: DashboardCard) => void
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void
workspaceContextMenusEnabled: boolean
onWorkspaceContextMenuOpenChange?: (open: boolean) => void
}
/** Map-only state and derivation stay in the pop-out's lazy chunk. */
export function AgentDashboardMapView({
snapshot,
cards,
query,
onQueryChange,
filters,
onFiltersChange,
searchInputRef,
now,
dialogCard,
onDialogOpenChange,
onRevealAgent,
onOpenTerminal,
onSpawnAgent,
onSleepWorkspace,
workspaceContextMenusEnabled,
onWorkspaceContextMenuOpenChange
}: AgentDashboardMapViewProps): React.JSX.Element {
const agentTypes = useMemo(
() => [...countAgentMapAgentTypes(snapshot.cards).keys()],
[snapshot.cards]
)
const mapFilters = useAgentMapFilters(agentTypes)
const [showAgentlessWorkspaces, setShowAgentlessWorkspaces] = useState(false)
const [showOrchestrationLinks, setShowOrchestrationLinks] = useState(true)
const agentlessWorkspaces = useMemo(
() =>
selectAgentlessMapWorkspaces({
cards: snapshot.cards,
workspaces: snapshot.workspaces ?? [],
query: '',
filters: EMPTY_DASHBOARD_FILTERS
}),
[snapshot.cards, snapshot.workspaces]
)
// The map's own facets run here so the panel can report one shown-count that
// matches what the canvas actually draws.
const visibleCards = useMemo(
() =>
filterAgentMapCards({
cards,
enabledStates: mapFilters.states,
enabledHosts: mapFilters.hosts,
enabledAgentTypes: mapFilters.agentTypes,
timeRanges: mapFilters.timeRanges,
orchestrationOnly: mapFilters.orchestrationOnly,
now
}).filter((card) => !mapFilters.unreadOnly || card.unseen),
[
cards,
mapFilters.states,
mapFilters.hosts,
mapFilters.agentTypes,
mapFilters.timeRanges,
mapFilters.orchestrationOnly,
mapFilters.unreadOnly,
now
]
)
const visibleAgentlessWorkspaces = useMemo(
() =>
showAgentlessWorkspaces ? filterDashboardWorkspaces(agentlessWorkspaces, query, filters) : [],
[agentlessWorkspaces, filters, query, showAgentlessWorkspaces]
)
return (
<>
<AgentDashboardToolbar
cards={snapshot.cards}
filteredCount={cards.length}
query={query}
onQueryChange={onQueryChange}
filters={filters}
onFiltersChange={onFiltersChange}
searchInputRef={searchInputRef}
filterControl={
<AgentMapFilterPanel
cards={snapshot.cards}
shownCount={visibleCards.length}
filterOptions={snapshot.filterOptions}
filters={filters}
onFiltersChange={onFiltersChange}
map={mapFilters}
agentlessWorkspaceCount={agentlessWorkspaces.length}
showAgentlessWorkspaces={showAgentlessWorkspaces}
onShowAgentlessWorkspacesChange={setShowAgentlessWorkspaces}
showOrchestrationLinks={showOrchestrationLinks}
onShowOrchestrationLinksChange={setShowOrchestrationLinks}
/>
}
/>
<AgentMapFilterChips
map={mapFilters}
filters={filters}
onFiltersChange={onFiltersChange}
projectLabel={(id) => snapshot.cards.find((card) => card.repoId === id)?.repoName ?? id}
statusLabel={(id) =>
snapshot.cards.find((card) => card.workspaceStatusId === id)?.workspaceStatusLabel ?? id
}
showAgentlessWorkspaces={showAgentlessWorkspaces}
onShowAgentlessWorkspacesChange={setShowAgentlessWorkspaces}
showOrchestrationLinks={showOrchestrationLinks}
onShowOrchestrationLinksChange={setShowOrchestrationLinks}
onClear={() => {
onFiltersChange(EMPTY_DASHBOARD_FILTERS)
mapFilters.reset()
setShowAgentlessWorkspaces(false)
setShowOrchestrationLinks(true)
}}
/>
<div className={cn('flex min-h-0 flex-1', dialogCard && 'flex-row-reverse')}>
<Suspense fallback={null}>
<AgentMap
cards={visibleCards}
workspaces={visibleAgentlessWorkspaces}
repoIconsByRepoId={snapshot.repoIconsByRepoId}
now={now}
className={dialogCard ? 'w-1/2 flex-none' : undefined}
selectedPaneKey={dialogCard?.paneKey}
enabledHosts={mapFilters.hosts}
showOrchestrationLinks={showOrchestrationLinks}
launchableAgentsByWorktreeId={snapshot.launchableAgentsByWorktreeId}
workspaceContextMenusEnabled={workspaceContextMenusEnabled}
onWorkspaceContextMenuOpenChange={onWorkspaceContextMenuOpenChange}
onOpenTerminal={onOpenTerminal}
onSpawnAgent={onSpawnAgent}
onSleepWorkspace={onSleepWorkspace}
/>
</Suspense>
{dialogCard ? (
<AgentTerminalPanel
card={dialogCard}
onOpenChange={onDialogOpenChange}
onReveal={onRevealAgent}
className="mr-0 animate-in fade-in-0 slide-in-from-left-2 duration-200 motion-reduce:animate-none"
/>
) : null}
</div>
</>
)
}
@@ -1,749 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AgentMap } from './AgentMap'
import { agentMapAttentionMarkerScale } from './agent-map-node-presentation'
import type { AgentMapState } from './agent-map-filter'
import type { DashboardCardHostKind } from '../../../../shared/dashboard-snapshot'
import { AGENT_MAP_AGENT_RADIUS } from './agent-map-layout'
import { card, installAgentMapEnvironment, NOW, renderMap } from './agent-map-render-test-harness'
describe('AgentMap', () => {
const environment = installAgentMapEnvironment()
it('renders the amber marker only for unread agents', () => {
const finished = card({
paneKey: 'done',
conversationName: 'Finished agent',
bucket: 'done',
dotState: 'done',
finishedAt: NOW - 60_000,
unseen: true
})
renderMap([card(), finished])
const workingNode = screen.getByRole('button', { name: /Agent alpha/ })
const doneNode = screen.getByRole('button', { name: /Finished agent/ })
expect(workingNode).toHaveClass('fleet-status-working')
expect(doneNode).toHaveClass('fleet-status-done')
expect(workingNode.querySelector('.agent-map-agent-icon svg')).toBeInTheDocument()
expect(workingNode.querySelector('[data-agent-unread-marker]')).not.toBeInTheDocument()
expect(doneNode.querySelector('.agent-map-agent-icon svg')).toBeInTheDocument()
const unreadMarker = doneNode.querySelector('[data-agent-unread-marker]')
expect(unreadMarker).toHaveClass('agent-map-agent-unread-mark')
// On the ring circumference at the top-left diagonal, where the halo breaks the ring.
const onRing = String(-AGENT_MAP_AGENT_RADIUS * Math.SQRT1_2)
expect(unreadMarker).toHaveAttribute('cx', onRing)
expect(unreadMarker).toHaveAttribute('cy', onRing)
expect(Number(unreadMarker?.getAttribute('r'))).toBeGreaterThanOrEqual(
AGENT_MAP_AGENT_RADIUS * 0.225
)
expect(unreadMarker).toHaveAttribute('vector-effect', 'none')
expect(doneNode).toHaveAccessibleName(/unread/)
})
it('lets attention override working on the worktree glow', () => {
const done = card({
paneKey: 'done',
conversationName: 'Finished agent',
worktreeId: 'worktree-done',
worktreeName: 'Finished worktree',
bucket: 'done',
dotState: 'done',
finishedAt: NOW - 60_000
})
const waiting = card({
paneKey: 'waiting',
conversationName: 'Question agent',
bucket: 'attention',
dotState: 'waiting'
})
const { container } = renderMap([card(), waiting, done])
const workingNode = screen.getByRole('button', { name: /Agent alpha/ })
const waitingNode = screen.getByRole('button', { name: /Question agent/ })
const doneNode = screen.getByRole('button', { name: /Finished agent/ })
const workingRing = screen.getByRole('button', {
name: /Open Agent map worktree details/
})
const doneRing = screen.getByRole('button', {
name: /Open Finished worktree worktree details/
})
expect(workingNode.querySelector('[data-agent-map-agent-status-glow]')).toHaveAttribute(
'data-agent-active-status',
'working'
)
expect(waitingNode.querySelector('[data-agent-map-agent-status-glow]')).toHaveAttribute(
'data-agent-active-status',
'waiting'
)
expect(doneNode.querySelector('[data-agent-map-agent-status-glow]')).not.toBeInTheDocument()
expect(workingRing).toHaveClass('is-waiting')
expect(workingRing).not.toHaveClass('is-working')
expect(doneRing).not.toHaveClass('is-working')
expect(container.querySelector('[data-agent-map-worktree-status-glow]')).toHaveAttribute(
'data-worktree-active-status',
'waiting'
)
expect(container.querySelectorAll('[data-agent-map-worktree-status-glow]')).toHaveLength(1)
})
it('keeps glow markup bounded for a large visible worktree', () => {
const cards = Array.from({ length: 120 }, (_, index) => {
const working = index % 2 === 0
return card({
paneKey: `pane-${index}`,
ptyId: `pty-${index}`,
tabId: `tab-${index}`,
leafId: `leaf-${index}`,
conversationName: `Agent ${index}`,
bucket: working ? 'working' : 'done',
dotState: working ? 'working' : 'done',
finishedAt: working ? null : NOW - 60_000
})
})
const { container } = renderMap(cards, { selectedPaneKey: 'pane-0' })
expect(container.querySelectorAll('[data-agent-map-agent-status-glow]')).toHaveLength(60)
expect(container.querySelectorAll('[data-agent-map-worktree-status-glow]')).toHaveLength(1)
expect(container.querySelectorAll('filter')).toHaveLength(0)
})
it('enlarges unread markers when the full fleet is zoomed out', () => {
const fleet = Array.from({ length: 72 }, (_, index) =>
card({
paneKey: `pane-${index}`,
ptyId: `pty-${index}`,
tabId: `tab-${index}`,
leafId: `leaf-${index}`,
worktreeId: `worktree-${index}`,
worktreeName: `Worktree ${index}`,
conversationName: `Agent ${index}`,
unseen: index === 0
})
)
const { container } = renderMap(fleet)
const marker = container.querySelector<SVGCircleElement>('[data-agent-unread-marker]')!
const agentMark =
marker.parentElement!.querySelector<SVGCircleElement>('.agent-map-agent-mark')!
expect(Number(marker.getAttribute('r'))).toBeGreaterThan(
Number(agentMark.getAttribute('r')) * 0.225
)
expect(marker).toHaveAttribute('vector-effect', 'none')
})
it('grows attention markers more gently than the inverse zoom while keeping a size floor', () => {
const mapScale = 0.2
const markerScale = agentMapAttentionMarkerScale(mapScale)
expect(markerScale).toBeGreaterThan(1)
expect(markerScale).toBeLessThan(1 / mapScale)
expect(AGENT_MAP_AGENT_RADIUS * 0.225 * markerScale * mapScale).toBeGreaterThanOrEqual(2.25)
})
it('shows worktree details and opens a running agent', () => {
const onOpenTerminal = vi.fn()
const running = card()
renderMap([running], { onOpenTerminal })
const ring = screen.getByRole('button', { name: 'Open Agent map worktree details' })
fireEvent.click(ring)
expect(ring).toHaveClass('is-open')
expect(screen.queryByRole('button', { name: 'Focus ring' })).not.toBeInTheDocument()
// The popover names the project; the map itself draws it uppercased.
expect(screen.getByText('Orca')).toBeInTheDocument()
expect(screen.getByText('1 agent · 1 active · 0 done')).toBeInTheDocument()
expect(screen.getByText('Agent alpha')).toBeInTheDocument()
fireEvent.click(screen.getAllByRole('button', { name: /Agent alpha/ })[1])
expect(onOpenTerminal).toHaveBeenCalledWith(running)
expect(ring).not.toHaveClass('is-open')
})
it('counts acknowledged completions as done in worktree details', () => {
renderMap([
card({
bucket: 'idle',
dotState: 'done',
unseen: false,
finishedAt: NOW - 60_000
})
])
fireEvent.click(screen.getByRole('button', { name: 'Open Agent map worktree details' }))
expect(screen.getByText('1 agent · 0 active · 1 done')).toBeInTheDocument()
})
it('starts a new agent from the worktree details picker', () => {
const onSpawnAgent = vi.fn()
renderMap([card()], {
onSpawnAgent,
launchableAgentsByWorktreeId: { 'worktree-1': ['claude', 'codex'] }
})
fireEvent.click(screen.getByRole('button', { name: 'Open Agent map worktree details' }))
expect(screen.getByText('Start a new agent')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Codex/ }))
// The raw worktree id, not the host-qualified map identity.
expect(onSpawnAgent).toHaveBeenCalledWith({ worktreeId: 'worktree-1', agent: 'codex' })
})
it('explains an empty picker rather than offering nothing', () => {
renderMap([card()], { onSpawnAgent: vi.fn() })
fireEvent.click(screen.getByRole('button', { name: 'Open Agent map worktree details' }))
expect(screen.getByText('No enabled agents detected.')).toBeInTheDocument()
})
it('offers sleep and launch on right-click where the store menu is unavailable', async () => {
const onSleepWorkspace = vi.fn()
renderMap([card()], {
onSleepWorkspace,
onSpawnAgent: vi.fn(),
launchableAgentsByWorktreeId: { 'worktree-1': ['claude'] }
})
fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' }), {
clientX: 10,
clientY: 10
})
expect(await screen.findByText('Start a new agent')).toBeInTheDocument()
fireEvent.click(screen.getByText('Sleep'))
expect(onSleepWorkspace).toHaveBeenCalledWith({ worktreeId: 'worktree-1' })
})
it('toggles worktree details from the keyboard', () => {
renderMap([card()])
const ring = screen.getByRole('button', { name: 'Open Agent map worktree details' })
fireEvent.keyDown(ring, { key: 'Enter' })
expect(ring).toHaveClass('is-open')
fireEvent.keyDown(ring, { key: 'Enter' })
expect(ring).not.toHaveClass('is-open')
})
it('labels folder workspaces without presenting them as worktrees', () => {
renderMap([card({ workspaceKind: 'folder', worktreeName: 'Documentation' })])
expect(
screen.getByRole('button', { name: 'Open Documentation folder workspace details' })
).toBeInTheDocument()
})
it('connects spawned workers beneath their visible parent', () => {
const parent = card({ paneKey: 'parent', conversationName: 'Coordinator' })
const child = card({
paneKey: 'child',
parentPaneKey: 'parent',
conversationName: 'Worker'
})
const nested = card({
paneKey: 'nested',
parentPaneKey: 'child',
conversationName: 'Subagent'
})
const orphan = card({
paneKey: 'orphan',
parentPaneKey: 'filtered-parent',
conversationName: 'Orphaned worker'
})
const { container } = renderMap([parent, child, nested, orphan])
const workerLink = container.querySelector('[data-child-pane-key="child"]')
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).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', () => {
const parent = card({
paneKey: 'parent',
worktreeId: 'parent-worktree',
worktreeName: 'Parent workspace',
conversationName: 'Coordinator'
})
const child = card({
paneKey: 'child',
worktreeId: 'child-worktree',
worktreeName: 'Child workspace',
parentPaneKey: 'parent',
conversationName: 'Worker'
})
const nested = card({
paneKey: 'nested',
worktreeId: 'nested-worktree',
worktreeName: 'Nested workspace',
parentPaneKey: 'child',
conversationName: 'Subagent'
})
const { container } = renderMap([parent, child, nested])
const workerLink = container.querySelector('[data-child-pane-key="child"]')
const nestedLink = container.querySelector('[data-child-pane-key="nested"]')
expect(workerLink).toHaveClass('agent-map-lineage-link', 'is-cross-worktree')
expect(workerLink).toHaveAttribute('data-agent-map-lineage-relation', 'orchestration')
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', () => {
const cards = Array.from({ length: 240 }, (_, index) =>
card({
paneKey: `agent-${index}`,
parentPaneKey: index === 0 ? undefined : `agent-${index - 1}`,
conversationName: `Agent ${index}`
})
)
const { container } = renderMap(cards, { selectedPaneKey: 'agent-0' })
expect(container.querySelectorAll('[data-agent-map-lineage-link]')).toHaveLength(239)
expect(
container.querySelectorAll('[data-agent-map-lineage-relation="orchestration"]')
).toHaveLength(239)
expect(container.querySelectorAll('[data-agent-map-lineage-relation="subagent"]')).toHaveLength(
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' }),
card({
paneKey: 'child',
worktreeId: 'child-worktree',
worktreeName: 'Child',
parentWorktreeId: 'parent-worktree'
}),
card({
paneKey: 'orphan',
worktreeId: 'orphan-worktree',
worktreeName: 'Orphan',
parentWorktreeId: 'filtered-parent'
})
])
const links = container.querySelectorAll('[data-agent-map-worktree-lineage-link]')
expect(links).toHaveLength(1)
expect(links[0]).toHaveAttribute('data-parent-worktree-id', 'parent-worktree')
expect(links[0]).toHaveAttribute('data-child-worktree-id', 'child-worktree')
})
it('opens the shared dashboard terminal dialog when an agent is clicked', () => {
const onOpenTerminal = vi.fn()
const agent = card()
renderMap([agent], { onOpenTerminal })
fireEvent.click(screen.getByRole('button', { name: /Agent alpha/ }))
expect(onOpenTerminal).toHaveBeenCalledWith(agent)
})
it('keeps a selected node visible while compacting around an adjacent terminal', () => {
const { container } = renderMap([card()], { selectedPaneKey: 'pane-1' })
const selectedNode = screen.getByRole('button', { name: /Agent alpha/ })
expect(selectedNode).toHaveClass('is-selected')
expect(screen.getByRole('button', { name: /Agent alpha/ })).toHaveAttribute(
'aria-pressed',
'true'
)
const label = container.querySelector('.agent-map-worktree-label-group')!
expect(selectedNode.compareDocumentPosition(label) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(4)
expect(label.querySelector('rect')).not.toBeInTheDocument()
expect(screen.getByText('270%')).toBeInTheDocument()
expect(screen.queryByText('Map filters')).not.toBeInTheDocument()
})
it('narrows the map to the enabled hosts', () => {
render(
<TooltipProvider>
<AgentMap
cards={[
card({ paneKey: 'pane-local' }),
card({
paneKey: 'pane-ssh',
worktreeId: 'worktree-2',
worktreeName: 'Remote map',
hostKind: 'ssh'
})
]}
now={NOW}
enabledHosts={new Set<DashboardCardHostKind>(['ssh'])}
onOpenTerminal={vi.fn()}
/>
</TooltipProvider>
)
expect(
screen.queryByRole('button', { name: 'Open Agent map worktree details' })
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Open Remote map worktree details' })
).toBeInTheDocument()
})
it('eases the viewport into the selected agent', () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal(
'matchMedia',
vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }))
)
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
)
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const agent = card()
const view = renderMap([agent])
view.rerender(
<AgentMap
cards={[agent]}
now={NOW}
onOpenTerminal={vi.fn()}
selectedPaneKey={agent.paneKey}
/>
)
act(() => frames.shift()?.(0))
expect(screen.getByText('100%')).toBeInTheDocument()
act(() => frames.shift()?.(120))
expect(screen.getByText('249%')).toBeInTheDocument()
act(() => frames.shift()?.(240))
expect(screen.getByText('270%')).toBeInTheDocument()
})
it('keeps the selected node centered when topology changes around it', () => {
const selected = card()
const view = renderMap([selected], { selectedPaneKey: selected.paneKey })
const svg = view.container.querySelector<SVGSVGElement>('.agent-map-canvas > svg')!
const selectedNode = (): SVGGElement =>
view.container.querySelector<SVGGElement>('.agent-map-agent-node.is-selected')!
const nodeCenter = (): [number, number] => {
const match = selectedNode()
.getAttribute('transform')
?.match(/translate\(([^ ]+) ([^)]+)\)/)
return [Number(match?.[1]), Number(match?.[2])]
}
const viewportCenter = (): [number, number] => {
const [x, y, width, height] = svg.getAttribute('viewBox')!.split(' ').map(Number)
return [x + width / 2, y + height / 2]
}
const originalNodeCenter = nodeCenter()
view.rerender(
<AgentMap
cards={[
card({
paneKey: 'earlier-project',
repoId: 'repo-0',
repoName: 'Earlier',
worktreeId: 'worktree-0'
}),
selected
]}
now={NOW}
onOpenTerminal={vi.fn()}
selectedPaneKey={selected.paneKey}
/>
)
expect(nodeCenter()).not.toEqual(originalNodeCenter)
expect(viewportCenter()[0]).toBeCloseTo(nodeCenter()[0])
expect(viewportCenter()[1]).toBeCloseTo(nodeCenter()[1])
})
it('increases map label scale when users zoom out', () => {
const { container } = renderMap([card()])
const labelGroup = container.querySelector('.agent-map-worktree-label')?.parentElement
const initialScale = Number(
labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1]
)
const readsBeforeZoom = environment.boundsSpy.mock.calls.length
fireEvent.click(screen.getByRole('button', { name: 'Zoom out' }))
const zoomedScale = Number(
labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1]
)
expect(environment.boundsSpy).toHaveBeenCalledTimes(readsBeforeZoom)
expect(zoomedScale).toBeGreaterThan(initialScale)
})
it('avoids idle pointer layout reads and batches active viewport updates by frame', () => {
const frames: FrameRequestCallback[] = []
const requestFrame = vi.fn((callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
vi.stubGlobal('requestAnimationFrame', requestFrame)
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const { container } = renderMap([card()])
const svg = container.querySelector<SVGSVGElement>('.agent-map-canvas > svg')!
Object.assign(svg, {
setPointerCapture: vi.fn(),
releasePointerCapture: vi.fn()
})
const readsBeforeHover = environment.boundsSpy.mock.calls.length
fireEvent.pointerMove(svg, { pointerId: 1, clientX: 20, clientY: 20 })
expect(environment.boundsSpy).toHaveBeenCalledTimes(readsBeforeHover)
fireEvent.pointerDown(svg, { pointerId: 1, clientX: 20, clientY: 20 })
expect(svg.setPointerCapture).toHaveBeenCalledWith(1)
const readsAfterPointerDown = environment.boundsSpy.mock.calls.length
const initialViewBox = svg.getAttribute('viewBox')
fireEvent.pointerMove(svg, { pointerId: 1, clientX: 30, clientY: 20 })
fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 20 })
expect(environment.boundsSpy).toHaveBeenCalledTimes(readsAfterPointerDown)
expect(requestFrame).toHaveBeenCalledOnce()
expect(svg).toHaveAttribute('viewBox', initialViewBox)
act(() => frames.shift()?.(0))
expect(svg.getAttribute('viewBox')).not.toBe(initialViewBox)
requestFrame.mockClear()
const readsBeforeWheel = environment.boundsSpy.mock.calls.length
const wheel = (): void => {
const event = new Event('wheel', { bubbles: true, cancelable: true })
Object.defineProperties(event, {
deltaY: { value: -10 },
clientX: { value: 100 },
clientY: { value: 100 }
})
fireEvent(svg, event)
expect(event.defaultPrevented).toBe(true)
}
wheel()
wheel()
expect(environment.boundsSpy).toHaveBeenCalledTimes(readsBeforeWheel + 1)
expect(requestFrame).toHaveBeenCalledOnce()
})
it('keeps active labels visible and progressively discloses quiet labels', () => {
const quiet = card({
paneKey: 'done',
worktreeId: 'quiet-worktree',
worktreeName: 'Quiet result',
conversationName: 'Finished agent',
bucket: 'done',
dotState: 'done',
finishedAt: NOW - 60_000,
unseen: false
})
const { container } = renderMap([card(), quiet])
const labels = [...container.querySelectorAll('.agent-map-worktree-label')]
const activeGroup = labels.find((label) => label.textContent === 'Agent map')?.parentElement
const quietGroup = labels.find((label) => label.textContent === 'Quiet result')?.parentElement
expect(activeGroup).toHaveClass('is-visible')
expect(quietGroup).not.toHaveClass('is-visible')
})
it('keeps acknowledged completions green and distinct from both unseen and idle', () => {
const seenResult = card({
paneKey: 'seen',
conversationName: 'Seen result',
bucket: 'done',
dotState: 'done',
finishedAt: NOW - 60_000,
unseen: false
})
const newResult = card({
paneKey: 'new',
conversationName: 'New result',
bucket: 'done',
dotState: 'done',
finishedAt: NOW - 2 * 60_000,
unseen: true
})
renderMap([card(), seenResult, newResult])
expect(screen.getByRole('button', { name: /Agent alpha/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /New result/ })).toHaveClass('fleet-status-done')
// Not `fleet-status-idle`: acknowledging a finish demotes it, but the work is still
// yours to land, so it must not look like a workspace that never ran.
const seen = screen.getByRole('button', { name: /Seen result/ })
expect(seen).toHaveClass('fleet-status-done-seen')
expect(seen).not.toHaveClass('fleet-status-idle')
expect(seen).not.toHaveClass('fleet-status-done')
expect(seen.querySelector('[data-agent-map-agent-status-glow]')).not.toBeInTheDocument()
})
it('hides the states the toolbar filter has muted', () => {
const done = card({
paneKey: 'done',
conversationName: 'Done agent',
unseen: true,
bucket: 'done',
dotState: 'done',
finishedAt: NOW - 60_000
})
renderMap([card(), done], { enabledStates: new Set<AgentMapState>(['done']) })
expect(screen.queryByRole('button', { name: /Agent alpha/ })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Done agent/ })).toBeInTheDocument()
})
it('preserves the viewport when filters temporarily empty the map', () => {
const agent = card()
const all = new Set<AgentMapState>(['attention', 'working', 'done', 'idle'])
const view = renderMap([agent], { enabledStates: all })
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
const viewBox = view.container
.querySelector<SVGSVGElement>('.agent-map-canvas > svg')!
.getAttribute('viewBox')
const onOpenTerminal = vi.fn()
view.rerender(
<AgentMap
cards={[agent]}
now={NOW}
onOpenTerminal={onOpenTerminal}
enabledStates={new Set<AgentMapState>(['done'])}
/>
)
expect(view.container.querySelector('.agent-map-canvas > svg')).not.toBeInTheDocument()
view.rerender(
<AgentMap cards={[agent]} now={NOW} onOpenTerminal={onOpenTerminal} enabledStates={all} />
)
expect(view.container.querySelector('.agent-map-canvas > svg')).toHaveAttribute(
'viewBox',
viewBox
)
})
it('preserves the viewport through an empty source snapshot', () => {
const agent = card()
const view = renderMap([agent])
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
const viewBox = view.container
.querySelector<SVGSVGElement>('.agent-map-canvas > svg')!
.getAttribute('viewBox')
view.rerender(<AgentMap cards={[]} now={NOW} onOpenTerminal={vi.fn()} />)
expect(view.container.querySelector('.agent-map-canvas > svg')).not.toBeInTheDocument()
view.rerender(<AgentMap cards={[agent]} now={NOW} onOpenTerminal={vi.fn()} />)
expect(view.container.querySelector('.agent-map-canvas > svg')).toHaveAttribute(
'viewBox',
viewBox
)
})
it('aggregates only acknowledged idle results without repacking topology', () => {
const results = Array.from({ length: 5 }, (_, index) =>
card({
paneKey: `done-${index}`,
conversationName: `Result ${index}`,
bucket: 'done',
dotState: 'done',
finishedAt: NOW - 60_000,
unseen: true
})
)
const view = renderMap(results)
const { container } = view
expect(container.querySelectorAll('[data-agent-map-agent]')).toHaveLength(5)
expect(container.querySelectorAll('.agent-map-aggregate-node')).toHaveLength(0)
view.rerender(
<AgentMap
cards={results.map((result) => ({ ...result, unseen: false }))}
now={NOW}
onOpenTerminal={vi.fn()}
/>
)
expect(container.querySelectorAll('[data-agent-map-agent]')).toHaveLength(0)
expect(container.querySelectorAll('.agent-map-aggregate-node')).toHaveLength(1)
view.rerender(
<AgentMap
cards={results.map((result) => ({ ...result, unseen: false }))}
now={NOW}
selectedPaneKey="done-0"
onOpenTerminal={vi.fn()}
/>
)
expect(container.querySelectorAll('[data-agent-map-agent]')).toHaveLength(5)
expect(container.querySelectorAll('.agent-map-aggregate-node')).toHaveLength(0)
})
})
@@ -1,114 +0,0 @@
import { useEffect, useMemo, useRef } from 'react'
import { cn } from '@/lib/utils'
import type {
DashboardCard,
DashboardCardHostKind,
DashboardSleepWorkspaceArgs,
DashboardSpawnAgentArgs,
DashboardWorkspace
} from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { AgentMapCanvas, type AgentMapCanvasHandle } from './AgentMapCanvas'
import { ALL_AGENT_MAP_HOSTS, filterAgentMapCards, type AgentMapState } from './agent-map-filter'
import { updateAgentMapLayout, type AgentMapLayoutCache } from './agent-map-layout'
import { selectAgentMapRecentFlareStatuses } from './agent-map-node-metadata'
import './agent-map.css'
type AgentMapProps = {
cards: DashboardCard[]
workspaces?: DashboardWorkspace[]
repoIconsByRepoId?: Record<string, RepoIcon | null>
now: number
className?: string
selectedPaneKey?: string | null
/** Pass-throughs in production — the board pre-filters so its panel can report
* a shown-count that matches the canvas. Kept so tests can empty the map. */
enabledStates?: ReadonlySet<AgentMapState>
enabledHosts?: ReadonlySet<DashboardCardHostKind>
/** Owned by the board's filter menu. Defaults to shown. */
showOrchestrationLinks?: boolean
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
workspaceContextMenusEnabled?: boolean
onWorkspaceContextMenuOpenChange?: (open: boolean) => void
onOpenTerminal: (card: DashboardCard) => void
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void
}
const ALL_AGENT_STATES: ReadonlySet<AgentMapState> = new Set<AgentMapState>([
'attention',
'working',
'done',
'idle'
])
const ALL_HOSTS: ReadonlySet<DashboardCardHostKind> = new Set(ALL_AGENT_MAP_HOSTS)
const EMPTY_WORKSPACES: DashboardWorkspace[] = []
export function AgentMap({
cards,
workspaces = EMPTY_WORKSPACES,
repoIconsByRepoId,
now,
className,
selectedPaneKey = null,
enabledStates = ALL_AGENT_STATES,
enabledHosts = ALL_HOSTS,
showOrchestrationLinks = true,
launchableAgentsByWorktreeId,
workspaceContextMenusEnabled = false,
onWorkspaceContextMenuOpenChange,
onOpenTerminal,
onSpawnAgent,
onSleepWorkspace
}: AgentMapProps): React.JSX.Element {
const canvasRef = useRef<AgentMapCanvasHandle>(null)
const layoutCacheRef = useRef<AgentMapLayoutCache | null>(null)
const visibleCards = useMemo(
() =>
filterAgentMapCards({
cards,
enabledStates,
enabledHosts
}),
[cards, enabledStates, enabledHosts]
)
const visibleWorkspaces = useMemo(
() => workspaces.filter((workspace) => enabledHosts.has(workspace.hostKind)),
[enabledHosts, workspaces]
)
const layoutResult = useMemo(
() => updateAgentMapLayout(layoutCacheRef.current, visibleCards, now, visibleWorkspaces),
[visibleCards, visibleWorkspaces, now]
)
const recentFlareStatuses = useMemo(
() => selectAgentMapRecentFlareStatuses(visibleCards),
[visibleCards]
)
useEffect(() => {
layoutCacheRef.current = layoutResult.cache
}, [layoutResult.cache])
const layout = layoutResult.layout
return (
<section className={cn('flex min-h-0 flex-1', className)}>
<div className="flex min-w-0 flex-1 flex-col">
<AgentMapCanvas
ref={canvasRef}
layout={layout}
repoIconsByRepoId={repoIconsByRepoId}
selectedPaneKey={selectedPaneKey}
allowAggregation
showOrchestrationLinks={showOrchestrationLinks}
recentFlareStatuses={recentFlareStatuses}
launchableAgentsByWorktreeId={launchableAgentsByWorktreeId}
workspaceContextMenusEnabled={workspaceContextMenusEnabled}
onWorkspaceContextMenuOpenChange={onWorkspaceContextMenuOpenChange}
onSelectAgent={onOpenTerminal}
onSpawnAgent={onSpawnAgent}
onSleepWorkspace={onSleepWorkspace}
/>
</div>
</section>
)
}
@@ -1,95 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
const ringRender = vi.hoisted(() => vi.fn())
vi.mock('./AgentMapWorktreeRingNode', () => ({
AgentMapWorktreeRingNode: ({ worktree }: { worktree: { id: string } }) => {
ringRender(worktree.id)
return <circle data-testid="instrumented-worktree" />
}
}))
import { AgentMap } from './AgentMap'
const NOW = 2_000_000_000
const CARD: DashboardCard = {
paneKey: 'pane-1',
ptyId: 'pty-1',
agentType: 'codex',
bucket: 'working',
dotState: 'working',
task: 'Measure map',
repoId: 'repo-1',
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
repoName: 'Orca',
worktreeName: 'Performance',
startedAt: NOW - 1_000,
finishedAt: null,
stateChangedAt: NOW - 1_000,
unseen: false
}
describe('AgentMapCanvas pointer performance', () => {
const frames: FrameRequestCallback[] = []
beforeEach(() => {
frames.length = 0
ringRender.mockClear()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 800,
bottom: 560,
width: 800,
height: 560,
toJSON: () => ({})
})
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('presents the map as a non-selectable panning surface', () => {
const { container } = render(<AgentMap cards={[CARD]} now={NOW} onOpenTerminal={vi.fn()} />)
const svg = container.querySelector<SVGSVGElement>('.agent-map-canvas > svg')!
expect(svg).toHaveClass('cursor-grab', 'touch-none', 'select-none', 'active:cursor-grabbing')
})
it('coalesces drag frames without rerendering worktree nodes', () => {
const { container } = render(<AgentMap cards={[CARD]} now={NOW} onOpenTerminal={vi.fn()} />)
const svg = container.querySelector<SVGSVGElement>('.agent-map-canvas > svg')!
Object.assign(svg, {
setPointerCapture: vi.fn(),
releasePointerCapture: vi.fn()
})
expect(ringRender).toHaveBeenCalledTimes(1)
fireEvent.pointerDown(svg, { pointerId: 2, button: 2, clientX: 20, clientY: 20 })
expect(svg.setPointerCapture).not.toHaveBeenCalled()
fireEvent.pointerDown(svg, { pointerId: 1, clientX: 20, clientY: 20 })
fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 20 })
fireEvent.pointerMove(svg, { pointerId: 1, clientX: 60, clientY: 20 })
expect(frames).toHaveLength(1)
act(() => frames.shift()?.(0))
expect(ringRender).toHaveBeenCalledTimes(1)
})
})
@@ -1,412 +0,0 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState
} from 'react'
import { translate } from '@/i18n/i18n'
import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'
import type {
DashboardCard,
DashboardSleepWorkspaceArgs,
DashboardSpawnAgentArgs
} from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { AgentMapAgentNode, AgentMapProjectRing, AgentMapLayout } from './agent-map-layout'
import type { AgentMapFlareStatus } from './agent-map-node-metadata'
import { AgentMapScene } from './AgentMapScene'
import { agentFocusZoom, clamp, MAX_ZOOM, MIN_ZOOM } from './agent-map-canvas-zoom'
import { AgentMapViewportControls } from './AgentMapViewportControls'
import {
agentMapAgents,
navigableAgentMapAgents,
nextDirectionalAgent
} from './agent-map-navigation'
import type { AgentMapViewport } from './agent-map-viewport-transition'
import { useAgentMapContextMenus } from './useAgentMapContextMenus'
import { useAgentMapCanvasSize } from './useAgentMapCanvasSize'
import { useAgentMapPointerHold } from './useAgentMapPointerHold'
import { useAgentMapMotionLayout } from './useAgentMapMotionLayout'
import { useAgentMapSelectedFocus } from './useAgentMapSelectedFocus'
import { useAgentMapViewportTransition } from './useAgentMapViewportTransition'
const AGENT_FOCUS_DURATION_MS = 240
export type AgentMapCanvasHandle = {
fit: () => void
focusProject: (project: AgentMapProjectRing) => void
}
type AgentMapCanvasProps = {
layout: AgentMapLayout
repoIconsByRepoId?: Record<string, RepoIcon | null>
selectedPaneKey: string | null
allowAggregation: boolean
showOrchestrationLinks: boolean
recentFlareStatuses: ReadonlyMap<string, AgentMapFlareStatus>
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
workspaceContextMenusEnabled?: boolean
onWorkspaceContextMenuOpenChange?: (open: boolean) => void
onSelectAgent: (card: DashboardCard) => void
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void
}
export const AgentMapCanvas = forwardRef<AgentMapCanvasHandle, AgentMapCanvasProps>(
function AgentMapCanvas(
{
layout,
repoIconsByRepoId,
selectedPaneKey,
allowAggregation,
showOrchestrationLinks,
recentFlareStatuses,
launchableAgentsByWorktreeId,
workspaceContextMenusEnabled = false,
onWorkspaceContextMenuOpenChange,
onSelectAgent,
onSpawnAgent,
onSleepWorkspace
},
forwardedRef
): React.JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const nodeRefs = useRef(new Map<string, SVGGElement>())
const dragRef = useRef<{
pointerId: number
point: AgentMapViewport['center']
center: AgentMapViewport['center']
worldPerPixelX: number
worldPerPixelY: number
} | null>(null)
const viewportFrameRef = useRef<number | null>(null)
const pendingViewportRef = useRef<AgentMapViewport | null>(null)
const interactionBoundsRef = useRef<DOMRect | null>(null)
const hasShownProjectsRef = useRef(layout.projects.length > 0)
const { held, hold, release: releaseHold, clearDrag } = useAgentMapPointerHold(dragRef)
const clearInteractionBounds = useCallback(() => {
interactionBoundsRef.current = null
}, [])
const size = useAgentMapCanvasSize(containerRef, clearInteractionBounds)
const [viewport, setViewport] = useState<AgentMapViewport>({
center: { x: layout.width / 2, y: layout.height / 2 },
zoom: 1
})
const prefersReducedMotion = usePrefersReducedMotion()
const motionLayout = useAgentMapMotionLayout(layout, prefersReducedMotion)
const viewportRef = useRef(viewport)
const { contextMenus, onOpenProjectContextMenu, onOpenWorkspaceContextMenu } =
useAgentMapContextMenus({
enabled: workspaceContextMenusEnabled,
launchableAgentsByWorktreeId,
onOpenChange: onWorkspaceContextMenuOpenChange,
onSpawnAgent,
onSleepWorkspace
})
const { center, zoom } = viewport
const agents = useMemo(() => agentMapAgents(layout), [layout])
const navigableAgents = useMemo(
() => navigableAgentMapAgents(layout, zoom, allowAggregation, selectedPaneKey),
[allowAggregation, layout, selectedPaneKey, zoom]
)
const hasProjects = layout.projects.length > 0
const aspect = size.width / Math.max(1, size.height)
const baseWidth = Math.max(layout.width, layout.height * aspect)
const baseHeight = baseWidth / aspect
const viewWidth = baseWidth / zoom
const viewHeight = baseHeight / zoom
const mapScale = size.width / viewWidth
const labelScale = Math.max(1, 1 / mapScale)
const viewBox = `${center.x - viewWidth / 2} ${center.y - viewHeight / 2} ${viewWidth} ${viewHeight}`
const focusZoom = agentFocusZoom(layout, size.width, size.height)
const resolveFocusZoom = useCallback((): number => {
const bounds = containerRef.current?.getBoundingClientRect()
return bounds && bounds.width > 0 && bounds.height > 0
? agentFocusZoom(layout, bounds.width, bounds.height)
: focusZoom
}, [focusZoom, layout])
const commitViewport = useCallback((next: AgentMapViewport): void => {
viewportRef.current = next
pendingViewportRef.current = null
interactionBoundsRef.current = null
setViewport(next)
}, [])
const { animate: animateViewport, stop: stopViewportTransition } =
useAgentMapViewportTransition({
durationMs: AGENT_FOCUS_DURATION_MS,
reducedMotion: prefersReducedMotion,
onFrame: commitViewport
})
useAgentMapSelectedFocus({
agents,
selectedPaneKey,
viewportRef,
resolveFocusZoom,
animateViewport,
stopViewportTransition
})
const applyViewport = useCallback(
(next: AgentMapViewport): void => {
stopViewportTransition()
commitViewport(next)
},
[commitViewport, stopViewportTransition]
)
const scheduleViewport = useCallback(
(next: AgentMapViewport): void => {
stopViewportTransition()
viewportRef.current = next
pendingViewportRef.current = next
if (viewportFrameRef.current !== null) {
return
}
viewportFrameRef.current = requestAnimationFrame(() => {
viewportFrameRef.current = null
interactionBoundsRef.current = null
const pending = pendingViewportRef.current
pendingViewportRef.current = null
if (pending) {
setViewport(pending)
}
})
},
[stopViewportTransition]
)
const fit = useCallback((): void => {
applyViewport({ center: { x: layout.width / 2, y: layout.height / 2 }, zoom: 1 })
}, [applyViewport, layout.height, layout.width])
const focusProject = useCallback(
(project: AgentMapProjectRing): void => {
const projectWidth = project.radius * 2.5
const projectHeight = project.radius * 2.5
applyViewport({
center: { x: project.x, y: project.y },
zoom: clamp(
Math.min(baseWidth / projectWidth, baseHeight / projectHeight),
MIN_ZOOM,
MAX_ZOOM
)
})
},
[applyViewport, baseHeight, baseWidth]
)
useImperativeHandle(forwardedRef, () => ({ fit, focusProject }), [fit, focusProject])
useEffect(() => {
if (hasProjects && !hasShownProjectsRef.current) {
hasShownProjectsRef.current = true
fit()
}
}, [fit, hasProjects])
useEffect(
() => () => {
if (viewportFrameRef.current !== null) {
cancelAnimationFrame(viewportFrameRef.current)
}
},
[]
)
const handleAgentKeyDown = useCallback(
(event: React.KeyboardEvent<SVGGElement>, agent: AgentMapAgentNode): void => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onSelectAgent(agent.card)
return
}
const direction =
event.key === 'ArrowLeft'
? { x: -1, y: 0 }
: event.key === 'ArrowRight'
? { x: 1, y: 0 }
: event.key === 'ArrowUp'
? { x: 0, y: -1 }
: event.key === 'ArrowDown'
? { x: 0, y: 1 }
: null
if (!direction) {
return
}
event.preventDefault()
const next = nextDirectionalAgent(agent, navigableAgents, direction)
nodeRefs.current.get(next?.card.paneKey ?? '')?.focus()
},
[navigableAgents, onSelectAgent]
)
const zoomAt = useCallback(
(nextZoom: number, clientX?: number, clientY?: number): void => {
const clampedZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM)
if (clientX === undefined || clientY === undefined) {
applyViewport({ ...viewportRef.current, zoom: clampedZoom })
return
}
const bounds =
interactionBoundsRef.current ?? svgRef.current?.getBoundingClientRect() ?? null
if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
applyViewport({ ...viewportRef.current, zoom: clampedZoom })
return
}
interactionBoundsRef.current = bounds
const current = viewportRef.current
const currentWidth = baseWidth / current.zoom
const currentHeight = baseHeight / current.zoom
const anchorX =
current.center.x -
currentWidth / 2 +
((clientX - bounds.left) / bounds.width) * currentWidth
const anchorY =
current.center.y -
currentHeight / 2 +
((clientY - bounds.top) / bounds.height) * currentHeight
const nextWidth = baseWidth / clampedZoom
const nextHeight = baseHeight / clampedZoom
const xRatio = (clientX - bounds.left) / bounds.width
const yRatio = (clientY - bounds.top) / bounds.height
scheduleViewport({
center: {
x: anchorX - (xRatio - 0.5) * nextWidth,
y: anchorY - (yRatio - 0.5) * nextHeight
},
zoom: clampedZoom
})
},
[applyViewport, baseHeight, baseWidth, scheduleViewport]
)
useEffect(() => {
if (!hasProjects) {
return
}
const svg = svgRef.current
if (!svg) {
return
}
const handleWheel = (event: WheelEvent): void => {
event.preventDefault()
zoomAt(
viewportRef.current.zoom * Math.exp(-event.deltaY * 0.0015),
event.clientX,
event.clientY
)
}
svg.addEventListener('wheel', handleWheel, { passive: false })
return () => svg.removeEventListener('wheel', handleWheel)
}, [hasProjects, zoomAt])
return (
<div ref={containerRef} className="agent-map-canvas relative min-h-0 flex-1 overflow-hidden">
{motionLayout.projects.length === 0 ? (
<div className="absolute inset-0 grid place-items-center text-center text-xs text-muted-foreground">
{translate('dashboardPopout.map.empty', 'No agents match the current filters.')}
</div>
) : (
<svg
ref={svgRef}
className="absolute inset-0 size-full cursor-grab touch-none select-none active:cursor-grabbing"
viewBox={viewBox}
aria-label={translate(
'dashboardPopout.map.canvasLabel',
'Nested project, workspace, and agent map'
)}
onPointerDown={(event) => {
if (event.button !== 0 || dragRef.current) {
return
}
if (
(event.target as Element).closest(
'[data-agent-map-agent], .agent-map-worktree-ring'
)
) {
return
}
const bounds = event.currentTarget.getBoundingClientRect()
if (bounds.width <= 0 || bounds.height <= 0) {
return
}
const current = viewportRef.current
dragRef.current = {
pointerId: event.pointerId,
point: { x: event.clientX, y: event.clientY },
center: current.center,
worldPerPixelX: baseWidth / current.zoom / bounds.width,
worldPerPixelY: baseHeight / current.zoom / bounds.height
}
hold(event.target as Element)
event.currentTarget.setPointerCapture(event.pointerId)
}}
onPointerMove={(event) => {
const drag = dragRef.current
if (!drag) {
releaseHold()
return
}
if (drag.pointerId !== event.pointerId) {
return
}
scheduleViewport({
center: {
x: drag.center.x - (event.clientX - drag.point.x) * drag.worldPerPixelX,
y: drag.center.y - (event.clientY - drag.point.y) * drag.worldPerPixelY
},
zoom: viewportRef.current.zoom
})
}}
onPointerUp={(event) => {
if (clearDrag(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
}}
onPointerCancel={(event) => {
clearDrag(event.pointerId)
}}
onLostPointerCapture={(event) => {
clearDrag(event.pointerId)
}}
onPointerLeave={() => {
if (!dragRef.current) {
releaseHold()
}
}}
>
<AgentMapScene
layout={motionLayout}
repoIconsByRepoId={repoIconsByRepoId}
zoom={zoom}
labelScale={labelScale}
mapScale={mapScale}
heldProjectId={held?.projectId ?? null}
heldWorktreeId={held?.worktreeId ?? null}
selectedPaneKey={selectedPaneKey}
allowAggregation={allowAggregation}
showOrchestrationLinks={showOrchestrationLinks}
recentFlareStatuses={recentFlareStatuses}
launchableAgentsByWorktreeId={launchableAgentsByWorktreeId}
nodeRefs={nodeRefs}
onSelectAgent={onSelectAgent}
onSpawnAgent={onSpawnAgent}
onOpenProjectContextMenu={onOpenProjectContextMenu}
onOpenWorkspaceContextMenu={onOpenWorkspaceContextMenu}
onAgentKeyDown={handleAgentKeyDown}
/>
</svg>
)}
<AgentMapViewportControls
zoom={zoom}
onFit={fit}
onZoomIn={() => zoomAt(viewportRef.current.zoom * 1.25)}
onZoomOut={() => zoomAt(viewportRef.current.zoom / 1.25)}
/>
{contextMenus}
</div>
)
}
)
@@ -1,59 +0,0 @@
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 />
</>
)
}
@@ -1,47 +0,0 @@
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { FilterOptionCount } from './FilterOptionCount'
type AgentMapFilterCheckboxProps = {
label: string
checked: boolean
count: number
onToggle: () => void
leading?: React.ReactNode
}
/** A filter row in the map's popover. Not a menu item: the panel holds sliders,
* and a Radix menu would swallow the arrow keys those need. */
export function AgentMapFilterCheckbox({
label,
checked,
count,
onToggle,
leading
}: AgentMapFilterCheckboxProps): React.JSX.Element {
return (
<button
type="button"
role="checkbox"
aria-checked={checked}
onClick={onToggle}
className={cn(
'flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left text-xs',
'hover:bg-accent/50',
count === 0 && !checked && 'opacity-50'
)}
>
<span
className={cn(
'flex size-3.5 shrink-0 items-center justify-center rounded-[4px] border',
checked ? 'border-foreground bg-foreground text-background' : 'border-input'
)}
>
{checked ? <Check className="size-2.5" strokeWidth={3.5} aria-hidden /> : null}
</span>
{leading}
<span className="min-w-0 flex-1 truncate">{label}</span>
<FilterOptionCount count={count} />
</button>
)
}
@@ -1,152 +0,0 @@
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import type { DashboardFilters } from './agent-board-filtering'
import { timeFieldLabel } from './agent-map-filter-labels'
import { agentStateLabel, reviewStateLabel } from './agent-dashboard-filter-options'
import {
activeAgentMapTimeFields,
agentMapTimeStopLabel,
FULL_AGENT_MAP_TIME_RANGE
} from './agent-map-time-filter'
import type { AgentMapFilterControls } from './useAgentMapFilters'
type Chip = { id: string; label: string; onRemove: () => void }
type AgentMapFilterChipsProps = {
map: AgentMapFilterControls
filters: DashboardFilters
onFiltersChange: (filters: DashboardFilters) => void
projectLabel: (id: string) => string
statusLabel: (id: string) => string
showAgentlessWorkspaces: boolean
onShowAgentlessWorkspacesChange: (show: boolean) => void
showOrchestrationLinks: boolean
onShowOrchestrationLinksChange: (show: boolean) => void
onClear: () => void
}
/** Every active facet as a removable chip. The panel's collapsed summaries say
* what is filtered; these are how you undo one without reopening the panel. */
export function AgentMapFilterChips({
map,
filters,
onFiltersChange,
projectLabel,
statusLabel,
showAgentlessWorkspaces,
onShowAgentlessWorkspacesChange,
showOrchestrationLinks,
onShowOrchestrationLinksChange,
onClear
}: AgentMapFilterChipsProps): React.JSX.Element | null {
const chips: Chip[] = []
const drop = <T extends string>(values: T[], value: T): T[] => values.filter((v) => v !== value)
for (const id of filters.projects) {
chips.push({
id: `project:${id}`,
label: projectLabel(id),
onRemove: () => onFiltersChange({ ...filters, projects: drop(filters.projects, id) })
})
}
for (const id of filters.workspaceStatuses) {
chips.push({
id: `status:${id}`,
label: statusLabel(id),
onRemove: () =>
onFiltersChange({ ...filters, workspaceStatuses: drop(filters.workspaceStatuses, id) })
})
}
for (const id of filters.reviewStates) {
chips.push({
id: `review:${id}`,
label: translate('dashboardPopout.filters.reviewChip', 'Review: {{state}}', {
state: reviewStateLabel(id)
}),
onRemove: () => onFiltersChange({ ...filters, reviewStates: drop(filters.reviewStates, id) })
})
}
if (map.states.size < 4) {
chips.push({
id: 'states',
label: translate('dashboardPopout.map.filters.stateChip', 'State: {{states}}', {
states: [...map.states].map(agentStateLabel).join(', ')
}),
onRemove: map.resetStates
})
}
for (const field of activeAgentMapTimeFields(map.timeRanges)) {
const range = map.timeRanges[field]
chips.push({
id: `time:${field}`,
label: `${timeFieldLabel(field)}: ${agentMapTimeStopLabel(range.min)}–${agentMapTimeStopLabel(range.max)}`,
onRemove: () => map.setTimeRange(field, { ...FULL_AGENT_MAP_TIME_RANGE })
})
}
if (map.unreadOnly) {
chips.push({
id: 'unread',
label: translate('dashboardPopout.map.quickView.unread', 'Unread'),
onRemove: () => map.setUnreadOnly(false)
})
}
if (map.orchestrationOnly) {
chips.push({
id: 'orchestration',
label: translate('dashboardPopout.map.quickView.orchestration', 'Orchestration'),
onRemove: () => map.setOrchestrationOnly(false)
})
}
if (showAgentlessWorkspaces) {
chips.push({
id: 'agentless',
label: translate(
'dashboardPopout.map.filters.agentlessWorkspaces',
'Workspaces without agents'
),
onRemove: () => onShowAgentlessWorkspacesChange(false)
})
}
if (!showOrchestrationLinks) {
chips.push({
id: 'orchestration-links',
label: translate(
'dashboardPopout.map.filters.orchestrationLinksHidden',
'Orchestration links hidden'
),
onRemove: () => onShowOrchestrationLinksChange(true)
})
}
// Agent chips need the option universe to know what "all" is, so they ride
// the panel's summary rather than a chip.
if (chips.length === 0) {
return null
}
return (
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border px-3 py-1.5">
{chips.map((chip) => (
<span
key={chip.id}
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted py-0.5 pr-1 pl-2 text-[11px]"
>
{chip.label}
<button
type="button"
onClick={chip.onRemove}
aria-label={translate('dashboardPopout.filters.removeChip', 'Remove {{filter}}', {
filter: chip.label
})}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
</button>
</span>
))}
<Button variant="ghost" size="xs" onClick={onClear} className="h-5 px-1.5 text-[11px]">
{translate('dashboardPopout.filters.clear', 'Clear')}
</Button>
</div>
)
}
@@ -1,73 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { ALL_AGENT_MAP_STATES, emptyAgentMapFilterState } from './agent-map-quick-views'
import { AgentMapFilterPanel } from './AgentMapFilterPanel'
import type { AgentMapFilterControls } from './useAgentMapFilters'
function card(agentType: string, paneKey: string): DashboardCard {
return {
paneKey,
ptyId: paneKey,
agentType,
bucket: 'working',
dotState: 'working',
task: '',
repoId: 'repo-1',
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
repoName: 'Orca',
worktreeName: 'Agent map',
startedAt: 1,
finishedAt: null,
stateChangedAt: 1,
unseen: false,
hostKind: agentType === 'codex' ? 'local' : 'ssh'
}
}
function controls(): AgentMapFilterControls {
return {
...emptyAgentMapFilterState(['claude', 'codex']),
states: new Set(ALL_AGENT_MAP_STATES),
activeCount: 0,
toggleState: vi.fn(),
resetStates: vi.fn(),
toggleAgentType: vi.fn(),
setTimeRange: vi.fn(),
resetTimeRanges: vi.fn(),
setUnreadOnly: vi.fn(),
setOrchestrationOnly: vi.fn(),
applyQuickView: vi.fn(),
reset: vi.fn()
}
}
describe('AgentMapFilterPanel', () => {
it('offers agent filtering without a host section', () => {
const cards = [card('codex', 'codex-pane'), card('claude', 'claude-pane')]
render(
<AgentMapFilterPanel
cards={cards}
shownCount={cards.length}
filters={{ projects: [], workspaceStatuses: [], reviewStates: [] }}
onFiltersChange={vi.fn()}
map={controls()}
agentlessWorkspaceCount={0}
showAgentlessWorkspaces={false}
onShowAgentlessWorkspacesChange={vi.fn()}
showOrchestrationLinks
onShowOrchestrationLinksChange={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: /^Filter/ }))
expect(screen.getByRole('button', { name: /Agents/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Hosts/ })).not.toBeInTheDocument()
})
})
@@ -1,363 +0,0 @@
import { ChevronDown, Filter, X } from 'lucide-react'
import { useState } from 'react'
import { AgentStateDot } from '@/components/AgentStateDot'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { getWorkspaceStatusVisualMeta } from '../sidebar/workspace-status'
import type { DashboardCard, DashboardFilterOptions } from '../../../../shared/dashboard-snapshot'
import {
activeDashboardFilterCount,
toggleDashboardFilter,
type DashboardFilters,
type DashboardReviewFilter
} from './agent-board-filtering'
import {
AGENT_STATE_ROWS,
agentStateLabel,
projectOptions,
REVIEW_OPTIONS,
reviewCountsByState,
reviewStateLabel,
workspaceStatusOptions
} from './agent-dashboard-filter-options'
import {
summarizeSelection,
summarizeTimeRanges,
type AgentMapSectionSummary
} from './agent-map-filter-summaries'
import { countAgentMapAgentTypes, countAgentMapCards } from './agent-map-filter'
import { AGENT_MAP_QUICK_VIEWS } from './agent-map-quick-views'
import { AGENT_MAP_TIME_FIELDS, type AgentMapTimeField } from './agent-map-time-filter'
import { AgentMapFilterCheckbox } from './AgentMapFilterCheckbox'
import { AgentMapFilterSection } from './AgentMapFilterSection'
import { AgentMapTimeRangeField } from './AgentMapTimeRangeField'
import type { AgentMapFilterControls } from './useAgentMapFilters'
import { timeFieldLabel } from './agent-map-filter-labels'
type AgentMapFilterPanelProps = {
cards: DashboardCard[]
shownCount: number
filterOptions?: DashboardFilterOptions
filters: DashboardFilters
onFiltersChange: (filters: DashboardFilters) => void
map: AgentMapFilterControls
agentlessWorkspaceCount: number
showAgentlessWorkspaces: boolean
onShowAgentlessWorkspacesChange: (show: boolean) => void
showOrchestrationLinks: boolean
onShowOrchestrationLinksChange: (show: boolean) => void
}
type SectionId = 'quick' | 'state' | 'agent' | 'time' | 'workspace' | 'content'
export function AgentMapFilterPanel({
cards,
shownCount,
filterOptions,
filters,
onFiltersChange,
map,
agentlessWorkspaceCount,
showAgentlessWorkspaces,
onShowAgentlessWorkspacesChange,
showOrchestrationLinks,
onShowOrchestrationLinksChange
}: AgentMapFilterPanelProps): React.JSX.Element {
const [open, setOpen] = useState<ReadonlySet<SectionId>>(() => new Set<SectionId>(['quick']))
const toggleSection = (id: SectionId, next: boolean): void =>
setOpen((current) => {
const updated = new Set(current)
if (next) {
updated.add(id)
} else {
updated.delete(id)
}
return updated
})
const stateCounts = countAgentMapCards(cards)
const agentTypeCounts = countAgentMapAgentTypes(cards)
const projects = projectOptions(cards, filterOptions?.projects)
const statuses = workspaceStatusOptions(cards, filterOptions?.workspaceStatuses)
const reviewCounts = reviewCountsByState(cards)
const agentTypes = [...agentTypeCounts.keys()]
const boardActive = activeDashboardFilterCount(filters)
const activeCount =
boardActive +
map.activeCount +
(showAgentlessWorkspaces ? 1 : 0) +
(showOrchestrationLinks ? 0 : 1)
const clearAll = (): void => {
onFiltersChange({ projects: [], workspaceStatuses: [], reviewStates: [] })
map.reset()
onShowAgentlessWorkspacesChange(false)
onShowOrchestrationLinksChange(true)
setOpen(new Set<SectionId>(['quick']))
}
const applyQuickView = (id: Parameters<typeof map.applyQuickView>[0]): void => {
onFiltersChange({ projects: [], workspaceStatuses: [], reviewStates: [] })
onShowAgentlessWorkspacesChange(false)
onShowOrchestrationLinksChange(true)
map.applyQuickView(id)
}
// Board-style facets: an empty list means "no filter", so the count is what is
// explicitly picked rather than what survives.
const pickedWorkspaceCount =
filters.workspaceStatuses.length +
filters.reviewStates.length +
(showAgentlessWorkspaces ? 1 : 0)
const workspaceSummary: AgentMapSectionSummary =
pickedWorkspaceCount === 0
? { text: translate('dashboardPopout.map.filters.summaryAll', 'All'), active: false }
: {
text: translate('dashboardPopout.map.filters.summarySelected', '{{count}} selected', {
count: pickedWorkspaceCount
}),
active: true
}
const projectSummary: AgentMapSectionSummary =
filters.projects.length === 0
? { text: translate('dashboardPopout.map.filters.summaryAll', 'All'), active: false }
: {
text:
filters.projects.length === 1
? (projects.find((p) => p.id === filters.projects[0])?.label ?? filters.projects[0])
: translate('dashboardPopout.map.filters.summaryCount', '{{shown}} of {{total}}', {
shown: filters.projects.length,
total: projects.length
}),
active: true
}
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="xs"
className={cn('h-7 gap-1.5 px-2 text-xs', activeCount > 0 && 'border-foreground/25')}
>
<Filter className="size-3" />
{translate('dashboardPopout.filters.label', 'Filter')}
{activeCount > 0 ? (
<span className="rounded-full bg-foreground px-1.5 py-px text-[10px] leading-none text-background">
{activeCount}
</span>
) : null}
<ChevronDown className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={6}
className="flex max-h-[min(34rem,calc(100vh-6rem))] w-80 flex-col gap-0 overflow-hidden p-0"
>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
<span className="text-xs font-semibold">
{translate('dashboardPopout.map.filters.title', 'Map controls')}
</span>
<button
type="button"
onClick={clearAll}
disabled={activeCount === 0}
className="text-[11px] text-muted-foreground hover:text-foreground disabled:opacity-40"
>
{translate('dashboardPopout.map.filters.reset', 'Reset')}
</button>
<span className="ml-auto text-[11px] tabular-nums">
<strong className="font-semibold">{shownCount}</strong>{' '}
<span className="text-muted-foreground">
{translate('dashboardPopout.map.filters.ofTotalAgents', 'of {{total}} agents shown', {
total: cards.length
})}
</span>
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek px-3 pb-3">
<AgentMapFilterSection
title={translate('dashboardPopout.map.filters.quickViews', 'Quick views')}
summary={{ text: '', active: false }}
open={open.has('quick')}
onOpenChange={(next) => toggleSection('quick', next)}
>
<div className="flex flex-wrap gap-1.5">
{AGENT_MAP_QUICK_VIEWS.map((view) => (
<button
key={view.id}
type="button"
onClick={() => applyQuickView(view.id)}
className="rounded-md bg-muted px-2 py-1 text-[11px] hover:bg-accent"
>
{view.label()}
</button>
))}
</div>
</AgentMapFilterSection>
<AgentMapFilterSection
title={translate('dashboardPopout.map.filters.showStates', 'Agent states')}
summary={summarizeSelection(map.states, AGENT_STATE_ROWS.length, agentStateLabel)}
open={open.has('state')}
onOpenChange={(next) => toggleSection('state', next)}
>
{AGENT_STATE_ROWS.map(({ state, dotState }) => (
<AgentMapFilterCheckbox
key={state}
label={agentStateLabel(state)}
checked={map.states.has(state)}
count={stateCounts[state]}
onToggle={() => map.toggleState(state)}
leading={<AgentStateDot state={dotState} size="md" />}
/>
))}
</AgentMapFilterSection>
{agentTypes.length > 1 ? (
<AgentMapFilterSection
title={translate('dashboardPopout.map.filters.agents', 'Agents')}
summary={summarizeSelection(map.agentTypes, agentTypes.length, (id) => id)}
open={open.has('agent')}
onOpenChange={(next) => toggleSection('agent', next)}
>
{agentTypes.map((agentType) => (
<AgentMapFilterCheckbox
key={agentType}
label={agentType}
checked={map.agentTypes.has(agentType)}
count={agentTypeCounts.get(agentType) ?? 0}
onToggle={() => map.toggleAgentType(agentType)}
/>
))}
</AgentMapFilterSection>
) : null}
<AgentMapFilterSection
title={translate('dashboardPopout.map.filters.time', 'Time')}
summary={summarizeTimeRanges(map.timeRanges, timeFieldLabel)}
open={open.has('time')}
onOpenChange={(next) => toggleSection('time', next)}
>
{AGENT_MAP_TIME_FIELDS.map((field: AgentMapTimeField) => (
<AgentMapTimeRangeField
key={field}
label={timeFieldLabel(field)}
range={map.timeRanges[field]}
onChange={(range) => map.setTimeRange(field, range)}
/>
))}
<button
type="button"
onClick={map.resetTimeRanges}
className="px-1.5 text-[10.5px] text-muted-foreground hover:text-foreground"
>
{translate('dashboardPopout.map.filters.resetRanges', 'Reset ranges')}
</button>
</AgentMapFilterSection>
<AgentMapFilterSection
title={translate('dashboardPopout.filters.project', 'Project')}
summary={projectSummary}
open={open.has('workspace')}
onOpenChange={(next) => toggleSection('workspace', next)}
>
{projects.map((option) => (
<AgentMapFilterCheckbox
key={option.id}
label={option.label}
checked={filters.projects.includes(option.id)}
count={option.count}
onToggle={() =>
onFiltersChange({
...filters,
projects: toggleDashboardFilter(filters.projects, option.id)
})
}
/>
))}
</AgentMapFilterSection>
<AgentMapFilterSection
title={translate('dashboardPopout.map.filters.workspace', 'Workspace')}
summary={workspaceSummary}
open={open.has('content')}
onOpenChange={(next) => toggleSection('content', next)}
>
{statuses.map((option) => {
const meta = getWorkspaceStatusVisualMeta({
id: option.id,
label: option.label,
color: option.color
})
return (
<AgentMapFilterCheckbox
key={option.id}
label={option.label}
checked={filters.workspaceStatuses.includes(option.id)}
count={option.count}
onToggle={() =>
onFiltersChange({
...filters,
workspaceStatuses: toggleDashboardFilter(filters.workspaceStatuses, option.id)
})
}
leading={<span className={cn('size-2 rounded-full', meta.swatch)} />}
/>
)
})}
<div className="my-1.5 border-t border-border" />
{REVIEW_OPTIONS.map((option: DashboardReviewFilter) => (
<AgentMapFilterCheckbox
key={option}
label={reviewStateLabel(option)}
checked={filters.reviewStates.includes(option)}
count={reviewCounts.get(option) ?? 0}
onToggle={() =>
onFiltersChange({
...filters,
reviewStates: toggleDashboardFilter(filters.reviewStates, option)
})
}
/>
))}
<div className="my-1.5 border-t border-border" />
<AgentMapFilterCheckbox
label={translate(
'dashboardPopout.map.filters.agentlessWorkspaces',
'Workspaces without agents'
)}
checked={showAgentlessWorkspaces}
count={agentlessWorkspaceCount}
onToggle={() => onShowAgentlessWorkspacesChange(!showAgentlessWorkspaces)}
/>
<AgentMapFilterCheckbox
label={translate(
'dashboardPopout.map.filters.orchestrationLinks',
'Orchestration links'
)}
checked={showOrchestrationLinks}
count={0}
onToggle={() => onShowOrchestrationLinksChange(!showOrchestrationLinks)}
/>
</AgentMapFilterSection>
{activeCount > 0 ? (
<button
type="button"
onClick={clearAll}
className="mt-2 flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
{translate('dashboardPopout.filters.clearAll', 'Clear all filters')}
</button>
) : null}
</div>
</PopoverContent>
</Popover>
)
}
@@ -1,55 +0,0 @@
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { AgentMapSectionSummary } from './agent-map-filter-summaries'
type AgentMapFilterSectionProps = {
title: string
/** Shown collapsed, so a closed row still says what it is doing. */
summary: AgentMapSectionSummary
open: boolean
onOpenChange: (open: boolean) => void
children: React.ReactNode
}
export function AgentMapFilterSection({
title,
summary,
open,
onOpenChange,
children
}: AgentMapFilterSectionProps): React.JSX.Element {
// A section doing something stays open: a collapsed row must never be the
// reason the map looks smaller than the filters claim.
const expanded = open || summary.active
return (
<div className="border-t border-border first:border-t-0">
<button
type="button"
aria-expanded={expanded}
onClick={() => onOpenChange(!expanded)}
className={cn(
'flex w-full items-center gap-1.5 py-2 text-left',
'text-[10px] font-semibold uppercase tracking-wider',
'text-muted-foreground hover:text-foreground'
)}
>
<ChevronRight
className={cn('size-3 transition-transform', expanded && 'rotate-90')}
aria-hidden
/>
<span>{title}</span>
{!expanded ? (
<span
className={cn(
'ml-auto text-[11px] font-normal normal-case tracking-normal tabular-nums',
summary.active ? 'text-foreground' : 'text-muted-foreground/80'
)}
>
{summary.text}
</span>
) : null}
</button>
{expanded ? <div className="pb-2.5">{children}</div> : null}
</div>
)
}
@@ -1,196 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, render, screen } from '@testing-library/react'
import { Profiler } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { AgentMap } from './AgentMap'
import { AGENT_MAP_ENTER_DURATION_MS, AGENT_MAP_EXIT_DURATION_MS } from './useAgentMapMotionLayout'
const NOW = 2_000_000_000
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
return {
paneKey: 'pane-1',
ptyId: 'pty-1',
agentType: 'codex',
bucket: 'working',
dotState: 'working',
task: 'Build map',
repoId: 'repo-1',
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
repoName: 'Orca',
worktreeName: 'Agent map',
conversationName: 'Agent alpha',
startedAt: NOW - 60_000,
finishedAt: null,
stateChangedAt: NOW - 1_000,
unseen: false,
hostKind: 'local',
workspaceKind: 'worktree',
...overrides
}
}
describe('Agent Map motion lifecycle', () => {
beforeEach(() => {
vi.stubGlobal(
'matchMedia',
vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }))
)
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 400,
bottom: 300,
width: 400,
height: 300,
toJSON: () => ({})
})
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('keeps agent positioning separate from the animated hover visual', () => {
render(<AgentMap cards={[card()]} now={NOW} onOpenTerminal={vi.fn()} />)
const agent = screen.getByRole('button', { name: /Agent alpha/ })
expect(agent).toHaveAttribute('transform', expect.stringMatching(/^translate\(/))
expect(agent.querySelector(':scope > .agent-map-agent-visual')).toBeInTheDocument()
})
it('retains created and removed agents for anchored enter and exit motion', () => {
const first = card()
const added = card({
paneKey: 'pane-2',
ptyId: 'pty-2',
tabId: 'tab-2',
leafId: 'leaf-2',
conversationName: 'Agent beta'
})
const view = render(<AgentMap cards={[first]} now={NOW} onOpenTerminal={vi.fn()} />)
vi.useFakeTimers()
view.rerender(<AgentMap cards={[first, added]} now={NOW} onOpenTerminal={vi.fn()} />)
const entering = screen.getByRole('button', { name: /Agent beta/ })
const position = entering.getAttribute('transform')
expect(entering).toHaveClass('is-entering')
act(() => vi.advanceTimersByTime(AGENT_MAP_ENTER_DURATION_MS))
expect(entering).not.toHaveClass('is-entering')
view.rerender(<AgentMap cards={[first]} now={NOW} onOpenTerminal={vi.fn()} />)
const exiting = view.container.querySelector<SVGGElement>('[aria-label^="Agent beta,"]')
expect(exiting).toHaveClass('is-exiting')
expect(exiting).toHaveAttribute('transform', position)
act(() => vi.advanceTimersByTime(AGENT_MAP_EXIT_DURATION_MS))
expect(view.container.querySelector('[aria-label^="Agent beta,"]')).not.toBeInTheDocument()
})
it('retains removed worktrees until their exit transition completes', () => {
const first = card()
const second = card({
paneKey: 'pane-2',
ptyId: 'pty-2',
tabId: 'tab-2',
leafId: 'leaf-2',
worktreeId: 'worktree-2',
worktreeName: 'Motion branch',
conversationName: 'Agent beta'
})
const view = render(<AgentMap cards={[first]} now={NOW} onOpenTerminal={vi.fn()} />)
vi.useFakeTimers()
view.rerender(<AgentMap cards={[first, second]} now={NOW} onOpenTerminal={vi.fn()} />)
const enteringGroup = view.container
.querySelector('[aria-label="Open Motion branch worktree details"]')
?.closest('.agent-map-worktree-group')
expect(enteringGroup).toHaveClass('is-entering')
act(() => vi.advanceTimersByTime(AGENT_MAP_ENTER_DURATION_MS))
expect(enteringGroup).not.toHaveClass('is-entering')
view.rerender(<AgentMap cards={[first]} now={NOW} onOpenTerminal={vi.fn()} />)
const ring = view.container.querySelector<SVGCircleElement>(
'[aria-label="Open Motion branch worktree details"]'
)
const exitingGroup = ring?.closest('.agent-map-worktree-group')
const exitingAgent = exitingGroup?.querySelector('[data-agent-map-agent]')
expect(exitingGroup).toHaveClass('is-exiting')
expect(exitingGroup).toHaveAttribute('aria-hidden', 'true')
expect(exitingAgent).toHaveAttribute('tabindex', '-1')
expect(exitingAgent).toHaveAttribute('aria-hidden', 'true')
act(() => vi.advanceTimersByTime(AGENT_MAP_EXIT_DURATION_MS))
expect(
view.container.querySelector('[aria-label="Open Motion branch worktree details"]')
).not.toBeInTheDocument()
})
it('does not restart an exit deadline for metadata-only layout updates', async () => {
const first = card()
const removed = card({ paneKey: 'pane-2', conversationName: 'Agent beta' })
const view = render(<AgentMap cards={[first, removed]} now={NOW} onOpenTerminal={vi.fn()} />)
vi.useFakeTimers()
view.rerender(<AgentMap cards={[first]} now={NOW} onOpenTerminal={vi.fn()} />)
await act(async () => {
vi.advanceTimersByTime(AGENT_MAP_EXIT_DURATION_MS - 10)
})
view.rerender(<AgentMap cards={[first]} now={NOW + 30_000} onOpenTerminal={vi.fn()} />)
await act(async () => {
vi.advanceTimersByTime(10)
})
expect(view.container.querySelector('[aria-label^="Agent beta,"]')).not.toBeInTheDocument()
})
it('commits a metadata-only layout update once', () => {
let commitCount = 0
const view = render(
<Profiler id="agent-map" onRender={() => (commitCount += 1)}>
<AgentMap cards={[card()]} now={NOW} onOpenTerminal={vi.fn()} />
</Profiler>
)
commitCount = 0
view.rerender(
<Profiler id="agent-map" onRender={() => (commitCount += 1)}>
<AgentMap cards={[card()]} now={NOW + 30_000} onOpenTerminal={vi.fn()} />
</Profiler>
)
expect(commitCount).toBe(1)
})
it('makes descendants non-interactive while their project exits', () => {
const first = card()
const removed = card({
paneKey: 'pane-2',
repoId: 'repo-2',
repoName: 'Removed project',
worktreeId: 'worktree-2',
worktreeName: 'Removed branch',
conversationName: 'Agent beta'
})
const view = render(<AgentMap cards={[first, removed]} now={NOW} onOpenTerminal={vi.fn()} />)
vi.useFakeTimers()
view.rerender(<AgentMap cards={[first]} now={NOW} onOpenTerminal={vi.fn()} />)
const exitingProject = view.container.querySelector('.agent-map-project-node.is-exiting')
const exitingAgent = exitingProject?.querySelector('[data-agent-map-agent]')
expect(exitingProject).toHaveAttribute('aria-hidden', 'true')
expect(exitingAgent).toHaveAttribute('tabindex', '-1')
expect(exitingAgent).toHaveAttribute('aria-hidden', 'true')
})
})
@@ -1,114 +0,0 @@
import { useEffect, useMemo, useRef } from 'react'
import { Plus } from 'lucide-react'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { useAppStore } from '@/store'
import { getRepoHeaderCreateState } from '@/components/sidebar/repo-header-create-state'
import { translate } from '@/i18n/i18n'
const FOLDER_PROJECT_PREFIX = 'folder-workspace:'
export type AgentMapProjectContextMenuRequest = {
id: number
projectId: string
clientX: number
clientY: number
}
type AgentMapProjectContextMenuProps = {
request: AgentMapProjectContextMenuRequest
onOpenChange?: (open: boolean) => void
}
export function AgentMapProjectContextMenu({
request,
onOpenChange
}: AgentMapProjectContextMenuProps): React.JSX.Element | null {
const triggerRef = useRef<HTMLSpanElement>(null)
const repos = useAppStore((state) => state.repos)
const projectGroups = useAppStore((state) => state.projectGroups)
const target = useMemo(() => {
if (request.projectId.startsWith(FOLDER_PROJECT_PREFIX)) {
const groupId = request.projectId.slice(FOLDER_PROJECT_PREFIX.length)
const groups = projectGroups.filter((group) => group.id === groupId)
return groups.length === 1 ? { kind: 'folder' as const, group: groups[0] } : null
}
const owners = repos.filter((repo) => repo.id === request.projectId)
return owners.length === 1 ? { kind: 'repo' as const, repo: owners[0] } : null
}, [projectGroups, repos, request.projectId])
const repo = target?.kind === 'repo' ? target.repo : null
const sshStatus = useAppStore((state) =>
repo?.connectionId ? (state.sshConnectionStates.get(repo.connectionId)?.status ?? null) : null
)
const openModal = useAppStore((state) => state.openModal)
useEffect(() => {
if (!target) {
onOpenChange?.(false)
return
}
triggerRef.current?.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: request.clientX,
clientY: request.clientY,
button: 2
})
)
}, [onOpenChange, request, target])
if (!target) {
return null
}
const label = target.kind === 'repo' ? target.repo.displayName : target.group.name
const createState =
target.kind === 'repo'
? getRepoHeaderCreateState({ repo: target.repo, label, sshStatus })
: {
disabled: false,
tooltip: translate(
'auto.components.sidebar.repo.header.create.state.62e71f2d5d',
'Create workspace for {{value0}}',
{ value0: label }
),
ariaLabel: translate(
'auto.components.sidebar.repo.header.create.state.62e71f2d5d',
'Create workspace for {{value0}}',
{ value0: label }
)
}
return (
<div className="pointer-events-none absolute inset-0">
<ContextMenu onOpenChange={onOpenChange}>
<ContextMenuTrigger asChild>
<span ref={triggerRef} aria-hidden />
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuLabel>{label}</ContextMenuLabel>
<ContextMenuItem
disabled={createState.disabled}
aria-label={createState.ariaLabel}
onSelect={() => {
openModal(
'new-workspace-composer',
target.kind === 'repo'
? { initialRepoId: target.repo.id, telemetrySource: 'sidebar' }
: { initialProjectGroupId: target.group.id, telemetrySource: 'sidebar' }
)
}}
>
<Plus />
{createState.tooltip}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div>
)
}
@@ -1,29 +0,0 @@
import { Suspense } from 'react'
import { lazyWithRetry } from '@/lib/lazy-with-retry'
import type { AgentMapProjectContextMenuRequest } from './AgentMapProjectContextMenu'
const AgentMapProjectContextMenu = lazyWithRetry(
() =>
import('./AgentMapProjectContextMenu').then((module) => ({
default: module.AgentMapProjectContextMenu
})),
{ reloadKey: 'agent-map-project-context-menu' }
)
type AgentMapProjectContextMenuLoaderProps = {
request: AgentMapProjectContextMenuRequest
onOpenChange?: (open: boolean) => void
}
export function AgentMapProjectContextMenuLoader({
request,
onOpenChange
}: AgentMapProjectContextMenuLoaderProps): React.JSX.Element {
return (
<Suspense fallback={null}>
<AgentMapProjectContextMenu request={request} onOpenChange={onOpenChange} />
</Suspense>
)
}
export type { AgentMapProjectContextMenuRequest }
@@ -1,126 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { AgentMapLayout } from './agent-map-layout'
import { AgentMapScene } from './AgentMapScene'
import { TooltipProvider } from '@/components/ui/tooltip'
const LAYOUT: AgentMapLayout = {
projects: [
{
id: 'repo-1',
name: 'Orca',
x: 120,
y: 120,
radius: 96,
worktrees: [],
agentCount: 1
}
],
width: 240,
height: 240,
topologyKey: 'repo-1'
}
describe('AgentMapScene project labels', () => {
it('renders the configured repository image next to its name', () => {
const { container } = render(
<svg>
<AgentMapScene
layout={LAYOUT}
repoIconsByRepoId={{
'repo-1': {
type: 'image',
src: 'data:image/png;base64,AAAA',
source: 'upload'
}
}}
zoom={1}
labelScale={1}
mapScale={0.5}
heldProjectId={null}
heldWorktreeId={null}
selectedPaneKey={null}
allowAggregation
showOrchestrationLinks
recentFlareStatuses={new Map()}
nodeRefs={{ current: new Map() }}
onSelectAgent={vi.fn()}
onAgentKeyDown={vi.fn()}
/>
</svg>
)
const label = container.querySelector('.agent-map-project-label')!
expect(label).toHaveTextContent('ORCA')
expect(label).toHaveClass('agent-map-project-label')
expect(label.querySelector('.agent-map-project-name')).toHaveTextContent('ORCA')
expect(label.querySelector('img')).toHaveAttribute('src', 'data:image/png;base64,AAAA')
expect(label.firstElementChild?.querySelector('img')).toBeInTheDocument()
expect(container.querySelector('.agent-map-project-label-frame')).toHaveAttribute('x', '-48')
expect(container.querySelector('.agent-map-project-label-frame')).toHaveAttribute('width', '96')
})
it('labels an SSH-backed project ring with its saved host', () => {
const sshLayout: AgentMapLayout = {
...LAYOUT,
projects: [
{
...LAYOUT.projects[0],
worktrees: [
{
id: 'worktree-1:openclaw',
worktreeId: 'worktree-1',
executionHostId: 'ssh:opaque-target',
hostKind: 'ssh',
hostLabel: 'openclaw',
name: 'humpback',
workspaceKind: 'worktree',
x: 120,
y: 120,
radius: 48,
agents: [],
statusCounts: {
working: 0,
monitoring: 0,
blocked: 0,
waiting: 0,
done: 0,
'done-seen': 0,
idle: 0
},
quiet: true
}
]
}
]
}
const { container } = render(
<TooltipProvider>
<svg>
<AgentMapScene
layout={sshLayout}
zoom={1}
labelScale={1}
mapScale={0.5}
heldProjectId={null}
heldWorktreeId={null}
selectedPaneKey={null}
allowAggregation
showOrchestrationLinks
recentFlareStatuses={new Map()}
nodeRefs={{ current: new Map() }}
onSelectAgent={vi.fn()}
onAgentKeyDown={vi.fn()}
/>
</svg>
</TooltipProvider>
)
const badge = container.querySelector('[data-dashboard-host-badge="ssh"]')
expect(badge).toHaveAccessibleName('SSH host · openclaw')
expect(badge).toHaveClass('agent-map-project-host-badge', 'pointer-events-auto')
})
})
@@ -1,37 +0,0 @@
import React from 'react'
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
// Why: hue alone can't carry 'waiting' on the map — it sits one step from
// blocked-red, and nodes shrink as you zoom out. The badge repeats the same
// question glyph every other surface uses, so the state is readable by shape.
/** Question badge marking an agent that is waiting on the user. */
export function AgentMapQuestionMarker({
radius,
markerScale
}: {
radius: number
markerScale: number
}): React.JSX.Element {
const iconSize = radius * 0.74 * markerScale
// Mirrors the unread dot across the node so the two never stack.
const offset = radius * Math.SQRT1_2
return (
<g transform={`translate(${offset} ${-offset})`} aria-hidden="true">
{/* Cuts the node ring out from behind the glyph. */}
<circle
className="agent-map-agent-question-backdrop"
data-agent-question-marker=""
r={iconSize * 0.62}
vectorEffect="none"
/>
<AgentQuestionIcon
className="agent-map-agent-question-icon"
x={-iconSize / 2}
y={-iconSize / 2}
width={iconSize}
height={iconSize}
/>
</g>
)
}
@@ -1,124 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { fireEvent } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { card, installAgentMapEnvironment, renderMap } from './agent-map-render-test-harness'
/** A ring has to stay open for as long as the pointer is working inside it —
* across its own contents, and across a pan drag that takes pointer capture. */
describe('AgentMap ring hover', () => {
installAgentMapEnvironment()
it('reveals the hovered workspace name and agent count above every other label', () => {
const { container } = renderMap([
card(),
card({ paneKey: 'pane-2', conversationName: 'Agent beta' })
])
expect(container.querySelector('[data-agent-map-hover-label]')).not.toBeInTheDocument()
fireEvent.pointerOver(container.querySelector('.agent-map-worktree-group')!)
const hovered = container.querySelector('[data-agent-map-hover-label]')!
expect(hovered.querySelector('.agent-map-worktree-label')).toHaveTextContent('Agent map')
expect(hovered.querySelector('.agent-map-worktree-count')).toHaveTextContent('2 agents')
expect(hovered.querySelector('.agent-map-worktree-label-group')).toHaveClass(
'is-active',
'is-count-visible'
)
// The hovered name is hoisted, not duplicated, and draws after every ring.
const labels = container.querySelectorAll('.agent-map-worktree-label-group')
expect(labels).toHaveLength(1)
const lastRing = [...container.querySelectorAll('[data-agent-map-worktree]')].at(-1)!
expect(lastRing.compareDocumentPosition(labels[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(4)
})
it('hides the hovered workspace label again when the pointer leaves', () => {
const { container } = renderMap([card()])
const group = container.querySelector('.agent-map-worktree-group')!
fireEvent.pointerOver(group)
expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument()
fireEvent.pointerOut(group)
expect(container.querySelector('[data-agent-map-hover-label]')).not.toBeInTheDocument()
})
it('keeps the pressed rings lit for the whole pan drag', () => {
const { container } = renderMap([card()])
const svg = container.querySelector('svg')!
const projectRing = container.querySelector('[data-agent-map-project-id]')!
const worktreeGroup = container.querySelector('.agent-map-worktree-group')!
// Pointer capture retargets :hover to the <svg> mid-gesture, so CSS alone
// cannot hold the ring open — the class has to survive the drag.
fireEvent.pointerDown(projectRing, { button: 0, pointerId: 1 })
expect(projectRing).toHaveClass('is-held')
fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 24 })
expect(projectRing).toHaveClass('is-held')
fireEvent.pointerUp(svg, { pointerId: 1 })
expect(projectRing).not.toHaveClass('is-held')
expect(worktreeGroup).not.toHaveClass('is-held')
})
it('holds the workspace name up while panning from inside that workspace', () => {
const { container } = renderMap([card()])
const svg = container.querySelector('svg')!
const worktreeGroup = container.querySelector('[data-agent-map-worktree-id]')!
// The aggregate bubble sits inside the group but is not the ring, so a press
// there pans rather than opening the workspace popover.
fireEvent.pointerDown(worktreeGroup, { button: 0, pointerId: 1 })
fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 24 })
expect(worktreeGroup).toHaveClass('is-held')
expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument()
})
it('drops the held rings when the pan is cancelled', () => {
const { container } = renderMap([card()])
const svg = container.querySelector('svg')!
const projectRing = container.querySelector('[data-agent-map-project-id]')!
fireEvent.pointerDown(projectRing, { button: 0, pointerId: 1 })
fireEvent.pointerCancel(svg, { pointerId: 1 })
expect(projectRing).not.toHaveClass('is-held')
})
it('drops the held rings and drag when pointer capture is lost', () => {
const { container } = renderMap([card()])
const svg = container.querySelector('svg')!
const projectRing = container.querySelector('[data-agent-map-project-id]')!
fireEvent.pointerDown(projectRing, { button: 0, pointerId: 1 })
fireEvent.lostPointerCapture(svg, { pointerId: 1 })
expect(projectRing).not.toHaveClass('is-held')
})
it('keeps a focused workspace label visible after its pointer leaves', () => {
const { container } = renderMap([card()])
const group = container.querySelector('.agent-map-worktree-group')!
const ring = container.querySelector<SVGCircleElement>('.agent-map-worktree-ring')!
ring.focus()
fireEvent.pointerOver(group)
fireEvent.pointerOut(group)
expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument()
})
it('keeps a hovered workspace label visible after its focus leaves', () => {
const { container } = renderMap([card()])
const group = container.querySelector('.agent-map-worktree-group')!
const ring = container.querySelector<SVGCircleElement>('.agent-map-worktree-ring')!
fireEvent.pointerOver(group)
ring.focus()
ring.blur()
expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument()
})
})
@@ -1,304 +0,0 @@
import { memo, useCallback, useMemo, useState, type MutableRefObject } from 'react'
import { RepoIconGlyph } from '@/components/repo/repo-icon'
import { translate } from '@/i18n/i18n'
import type { DashboardCard, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type {
AgentMapAgentNode,
AgentMapLayout,
AgentMapProjectRing,
AgentMapWorktreeRing
} 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 type { AgentMapFlareStatus } from './agent-map-node-metadata'
import { AgentMapWorktreeLabel } from './AgentMapWorktreeLabel'
import { AgentMapWorktreeRingNode } from './AgentMapWorktreeRingNode'
import { DashboardHostBadge } from './DashboardHostBadge'
type AgentMapSceneProps = {
layout: AgentMapLayout
repoIconsByRepoId?: Record<string, RepoIcon | null>
zoom: number
labelScale: number
mapScale: number
/** Rings the pointer was pressed in; they stay lit for the whole pan drag. */
heldProjectId: string | null
heldWorktreeId: string | null
selectedPaneKey: string | null
allowAggregation: boolean
showOrchestrationLinks: boolean
recentFlareStatuses: ReadonlyMap<string, AgentMapFlareStatus>
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
nodeRefs: MutableRefObject<Map<string, SVGGElement>>
onSelectAgent: (card: DashboardCard) => void
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
onOpenProjectContextMenu?: (
event: React.MouseEvent<SVGCircleElement>,
project: AgentMapProjectRing
) => void
onOpenWorkspaceContextMenu?: (
event: React.MouseEvent<SVGCircleElement>,
worktree: AgentMapWorktreeRing
) => void
onAgentKeyDown: (event: React.KeyboardEvent<SVGGElement>, agent: AgentMapAgentNode) => void
}
function worktreeLineagePath(parent: AgentMapWorktreeRing, child: AgentMapWorktreeRing): string {
const startY = parent.y + parent.radius
const endY = child.y - child.radius
const branchY = (startY + endY) / 2
return `M ${parent.x} ${startY} C ${parent.x} ${branchY} ${child.x} ${branchY} ${child.x} ${endY}`
}
type VisibleAgentLocation = {
agent: AgentMapAgentNode
worktreeId: string
}
function agentLineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): string {
return agentMapDirectLineageChevronPath(parent, child)
}
/** Memoization keeps pointer panning to one SVG viewBox write, not a map rerender. */
export const AgentMapScene = memo(function AgentMapScene({
layout,
repoIconsByRepoId,
zoom,
labelScale,
mapScale,
heldProjectId,
heldWorktreeId,
selectedPaneKey,
allowAggregation,
showOrchestrationLinks,
recentFlareStatuses,
launchableAgentsByWorktreeId,
nodeRefs,
onSelectAgent,
onSpawnAgent,
onOpenProjectContextMenu,
onOpenWorkspaceContextMenu,
onAgentKeyDown
}: AgentMapSceneProps): React.JSX.Element {
const [hoveredWorktreeId, setHoveredWorktreeId] = useState<string | null>(null)
const [focusedWorktreeId, setFocusedWorktreeId] = useState<string | null>(null)
const activeWorktreeId = heldWorktreeId ?? hoveredWorktreeId ?? focusedWorktreeId
const handleLabelHoverChange = useCallback((worktreeId: string, active: boolean): void => {
setHoveredWorktreeId((current) =>
active ? worktreeId : current === worktreeId ? null : current
)
}, [])
const handleLabelFocusChange = useCallback((worktreeId: string, active: boolean): void => {
setFocusedWorktreeId((current) =>
active ? worktreeId : current === worktreeId ? null : current
)
}, [])
const visibleLabels = useMemo(
() => selectVisibleAgentMapLabels(layout, labelScale, mapScale),
[labelScale, layout, mapScale]
)
const activeWorktree = useMemo(() => {
if (!activeWorktreeId) {
return null
}
for (const project of layout.projects) {
for (const worktree of project.worktrees) {
if (worktree.id === activeWorktreeId) {
return worktree
}
}
}
return null
}, [activeWorktreeId, layout])
const visibleAgentsByPaneKey = useMemo(() => {
const agents = new Map<string, VisibleAgentLocation>()
for (const project of layout.projects) {
for (const worktree of project.worktrees) {
const selected = worktree.agents.some((agent) => agent.card.paneKey === selectedPaneKey)
if (!selected && shouldAggregateAgentMapWorktree(worktree, zoom, allowAggregation)) {
continue
}
for (const agent of worktree.agents) {
agents.set(agent.card.paneKey, { agent, worktreeId: worktree.id })
}
}
}
return agents
}, [allowAggregation, layout, selectedPaneKey, zoom])
return (
<>
{layout.projects.map((project) => {
const worktreesById = new Map(project.worktrees.map((worktree) => [worktree.id, worktree]))
const projectLabelHalfWidth = project.radius * mapScale
const projectHostsById = new Map<string, AgentMapWorktreeRing>()
for (const worktree of project.worktrees) {
if (worktree.hostKind === 'ssh' || worktree.hostKind === 'remote') {
projectHostsById.set(`${worktree.hostKind}:${worktree.executionHostId ?? ''}`, worktree)
}
}
const projectHosts = [...projectHostsById.values()]
const projectCountText = translate(
'dashboardPopout.map.projectCount',
'{{agents}} agents · {{workspaces}} workspaces',
{ agents: project.agentCount, workspaces: project.worktrees.length }
).toUpperCase()
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}
className={`agent-map-project-node${project.motionState ? ` is-${project.motionState}` : ''}${heldProjectId === project.id ? ' is-held' : ''}`}
data-agent-map-project-id={project.id}
aria-hidden={project.motionState === 'exiting' || undefined}
>
<circle
className="agent-map-project-ring"
data-agent-map-project=""
cx={project.x}
cy={project.y}
r={project.radius}
onContextMenu={
onOpenProjectContextMenu
? (event) => {
event.preventDefault()
event.stopPropagation()
onOpenProjectContextMenu(event, project)
}
: undefined
}
/>
<g className="agent-map-worktree-lineage-links" aria-hidden>
{project.worktrees.map((child) => {
const parent = child.parentId ? worktreesById.get(child.parentId) : undefined
return !parent || child.y <= parent.y ? null : (
<path
key={child.id}
className={`agent-map-worktree-lineage-link${child.motionState === 'exiting' || parent.motionState === 'exiting' ? ' is-exiting' : child.motionState === 'entering' || parent.motionState === 'entering' ? ' is-entering' : ''}`}
data-agent-map-worktree-lineage-link=""
data-parent-worktree-id={parent.worktreeId}
data-child-worktree-id={child.worktreeId}
d={worktreeLineagePath(parent, child)}
/>
)
})}
</g>
<g className="agent-map-lineage-links" aria-hidden>
{crossWorktreeLineage.map(({ parent, child }) => (
<path
key={child.card.paneKey}
className={`agent-map-lineage-link is-cross-worktree${parent.motionState === 'exiting' || child.motionState === 'exiting' ? ' is-exiting' : parent.motionState === 'entering' || child.motionState === 'entering' ? ' is-entering' : ''}`}
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
key={worktree.id}
project={project}
worktree={worktree}
zoom={zoom}
mapScale={mapScale}
held={heldWorktreeId === worktree.id}
selectedPaneKey={selectedPaneKey}
allowAggregation={allowAggregation}
showOrchestrationLinks={showOrchestrationLinks}
recentFlareStatuses={recentFlareStatuses}
launchableAgents={launchableAgentsByWorktreeId?.[worktree.worktreeId]}
nodeRefs={nodeRefs}
onSelectAgent={onSelectAgent}
onSpawnAgent={onSpawnAgent}
onOpenWorkspaceContextMenu={onOpenWorkspaceContextMenu}
onLabelHoverChange={handleLabelHoverChange}
onLabelFocusChange={handleLabelFocusChange}
onAgentKeyDown={onAgentKeyDown}
/>
))}
<g className="agent-map-worktree-label-layer">
{project.worktrees.map((worktree) =>
worktree.id === activeWorktreeId ? null : (
<AgentMapWorktreeLabel
key={worktree.id}
worktree={worktree}
visible={visibleLabels.worktreeIds.has(worktree.id)}
active={false}
labelScale={labelScale}
mapScale={mapScale}
/>
)
)}
</g>
<g
transform={`translate(${project.x} ${project.y - project.radius}) scale(${labelScale})`}
>
<foreignObject
className="agent-map-project-label-frame"
x={-projectLabelHalfWidth}
y={3}
width={projectLabelHalfWidth * 2}
height={18}
>
<div className="agent-map-project-label">
<RepoIconGlyph
repoIcon={repoIconsByRepoId?.[project.id] ?? null}
className="size-3 shrink-0"
iconClassName="size-3"
/>
<span className="agent-map-project-name min-w-0 truncate">
{project.name.toUpperCase()}
</span>
{projectHosts.map((host) => (
<DashboardHostBadge
key={`${host.hostKind}:${host.executionHostId ?? ''}`}
hostKind={host.hostKind}
executionHostId={host.executionHostId}
hostLabel={host.hostLabel}
keyboardFocusable
className="agent-map-project-host-badge"
/>
))}
</div>
</foreignObject>
{visibleLabels.projectCountIds.has(project.id) ? (
<text className="agent-map-project-count" y={32}>
{projectCountText}
</text>
) : null}
</g>
</g>
)
})}
{/* Drawn last so a hovered name clears every other project's rings, and
* outside the declutter pass so it reads at any zoom. */}
{activeWorktree ? (
<g className="agent-map-worktree-hover-label-layer" data-agent-map-hover-label="">
<AgentMapWorktreeLabel
worktree={activeWorktree}
visible={visibleLabels.worktreeIds.has(activeWorktree.id)}
active
labelScale={labelScale}
mapScale={mapScale}
/>
</g>
) : null}
</>
)
})
@@ -1,100 +0,0 @@
import { useEffect, useRef } from 'react'
import { Moon, Plus } from 'lucide-react'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { translate } from '@/i18n/i18n'
import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog'
import type {
DashboardSleepWorkspaceArgs,
DashboardSpawnAgentArgs
} from '../../../../shared/dashboard-snapshot'
import type { TuiAgent } from '../../../../shared/tui-agent'
export type AgentMapSnapshotWorkspaceMenuRequest = {
id: number
worktreeId: string
worktreeName: string
launchableAgents: readonly TuiAgent[]
clientX: number
clientY: number
}
type AgentMapSnapshotWorkspaceMenuProps = {
request: AgentMapSnapshotWorkspaceMenuRequest
onOpenChange?: (open: boolean) => void
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void
}
/**
* The workspace right-click menu for surfaces without the app store — the
* pop-out window. Its actions are relayed to the main renderer, so it offers
* only what a snapshot can describe, not the full sidebar menu.
*/
export function AgentMapSnapshotWorkspaceMenu({
request,
onOpenChange,
onSpawnAgent,
onSleepWorkspace
}: AgentMapSnapshotWorkspaceMenuProps): React.JSX.Element {
const triggerRef = useRef<HTMLSpanElement>(null)
useEffect(() => {
triggerRef.current?.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: request.clientX,
clientY: request.clientY,
button: 2
})
)
}, [request])
return (
<div className="pointer-events-none absolute inset-0">
<ContextMenu onOpenChange={onOpenChange}>
<ContextMenuTrigger asChild>
<span ref={triggerRef} aria-hidden />
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuLabel className="truncate">{request.worktreeName}</ContextMenuLabel>
{onSpawnAgent ? (
<ContextMenuSub>
<ContextMenuSubTrigger disabled={request.launchableAgents.length === 0}>
<Plus className="size-3.5" />
{translate('dashboardPopout.map.spawnAgent', 'Start a new agent')}
</ContextMenuSubTrigger>
<ContextMenuSubContent>
{request.launchableAgents.map((agent) => (
<ContextMenuItem
key={agent}
onSelect={() => onSpawnAgent({ worktreeId: request.worktreeId, agent })}
>
<AgentIcon agent={agent} size={14} />
{getAgentLabel(agent)}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
) : null}
{onSpawnAgent && onSleepWorkspace ? <ContextMenuSeparator /> : null}
{onSleepWorkspace ? (
<ContextMenuItem onSelect={() => onSleepWorkspace({ worktreeId: request.worktreeId })}>
<Moon className="size-3.5" />
{translate('dashboardPopout.map.sleepWorkspace', 'Sleep')}
</ContextMenuItem>
) : null}
</ContextMenuContent>
</ContextMenu>
</div>
)
}
@@ -1,96 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { describe, expect, it, vi } from 'vitest'
import { card, installAgentMapEnvironment, NOW, renderMap } from './agent-map-render-test-harness'
describe('AgentMap status glow', () => {
installAgentMapEnvironment()
it.each([
{ bucket: 'working', dotState: 'working', unseen: false, glows: true },
{ bucket: 'attention', dotState: 'waiting', unseen: false, glows: true },
{ bucket: 'attention', dotState: 'blocked', unseen: false, glows: true },
// An unread finish is the state the map exists to surface, so it halos like the
// rest. Acknowledging it drops the halo — that is the seen/unseen difference.
{ bucket: 'done', dotState: 'done', unseen: true, glows: true },
{ bucket: 'done', dotState: 'done', unseen: false, glows: false },
{ bucket: 'idle', dotState: 'idle', unseen: false, glows: false }
] as const)(
'applies the expected halo for $dotState agents (unseen: $unseen)',
({ glows, ...state }) => {
const { container } = renderMap([card(state)])
const glow = container.querySelector('[data-agent-map-agent-status-glow]')
if (glows) {
expect(glow).toHaveAttribute('data-agent-active-status', state.dotState)
return
}
expect(glow).not.toBeInTheDocument()
}
)
it('caps a 200-status burst at four flares without dropping static emphasis', () => {
const clock = vi.spyOn(Date, 'now').mockReturnValue(NOW)
const { container } = renderMap(
Array.from({ length: 200 }, (_, index) =>
card({
paneKey: `pane-${index}`,
ptyId: `pty-${index}`,
leafId: `leaf-${index}`,
bucket: 'done',
dotState: 'done',
unseen: true,
stateChangedAt: NOW
})
)
)
clock.mockRestore()
expect(container.querySelectorAll('[data-agent-map-agent-status-flare]')).toHaveLength(4)
expect(container.querySelectorAll('[data-agent-map-agent-status-glow]')).toHaveLength(200)
expect(container.querySelectorAll('.fleet-status-done .agent-map-agent-mark')).toHaveLength(200)
expect(container.querySelectorAll('[data-agent-unread-marker]')).toHaveLength(200)
})
it.each([
{ bucket: 'attention', dotState: 'waiting', className: 'fleet-status-waiting' },
{ bucket: 'done', dotState: 'done', className: 'fleet-status-done' }
] as const)('flares a fresh $dotState state', ({ bucket, dotState, className }) => {
const clock = vi.spyOn(Date, 'now').mockReturnValue(NOW)
const { container } = renderMap([card({ bucket, dotState, unseen: true, stateChangedAt: NOW })])
clock.mockRestore()
expect(container.querySelector('[data-agent-map-agent-status-flare]')).toHaveClass(className)
})
it.each([
{ dotState: 'waiting', marked: true },
{ dotState: 'working', marked: false },
{ dotState: 'blocked', marked: false }
] as const)('marks $dotState agents with a question badge: $marked', (state) => {
const { container } = renderMap([card({ bucket: 'attention', dotState: state.dotState })])
const marker = container.querySelector('[data-agent-question-marker]')
if (!state.marked) {
expect(marker).not.toBeInTheDocument()
return
}
expect(marker).toBeInTheDocument()
// Same glyph the sidebar and tabs use, not a map-local invention.
expect(container.querySelector('svg.agent-map-agent-question-icon')).toBeInTheDocument()
expect(marker!.parentElement!.querySelector('foreignObject')).not.toBeInTheDocument()
})
it('keeps the question badge clear of the unread dot', () => {
const { container } = renderMap([
card({ bucket: 'attention', dotState: 'waiting', unseen: true })
])
const question = container.querySelector('[data-agent-question-marker]')!.parentElement!
const unread = container.querySelector('[data-agent-unread-marker]')!
// Unread sits top-left, the badge top-right — opposite signs on x.
expect(question.getAttribute('transform')).toMatch(/translate\(\d/)
expect(Number(unread.getAttribute('cx'))).toBeLessThan(0)
})
})
@@ -1,416 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot'
import type * as AgentMapLayoutModule from './agent-map-layout'
import type * as AgentMapProjectPlacementModule from './agent-map-project-placement'
import { AGENT_MAP_TIME_MAX_INDEX, type AgentMapTimeRange } from './agent-map-time-filter'
/** Counts the packing work one slider interaction costs. `repacks` only rises
* when `updateAgentMapLayout` misses its topology cache and runs the full
* `deriveAgentMapLayout` again; `updates` counts every layout evaluation. */
const layoutCalls = vi.hoisted(() => ({ updates: 0, repacks: 0 }))
const packCalls = vi.hoisted(() => ({ count: 0 }))
vi.mock('./agent-map-layout', async (importOriginal) => {
const actual = await importOriginal<typeof AgentMapLayoutModule>()
return {
...actual,
updateAgentMapLayout: (
...args: Parameters<typeof actual.updateAgentMapLayout>
): ReturnType<typeof actual.updateAgentMapLayout> => {
layoutCalls.updates += 1
const result = actual.updateAgentMapLayout(...args)
// A fresh cache object is returned only on the deriveAgentMapLayout path.
if (result.cache !== args[0]) {
layoutCalls.repacks += 1
}
return result
}
}
})
// Second, independent counter: the packer runs once per non-empty repack.
vi.mock('./agent-map-project-placement', async (importOriginal) => {
const actual = await importOriginal<typeof AgentMapProjectPlacementModule>()
return {
...actual,
placeAgentMapProjects: (
...args: Parameters<typeof actual.placeAgentMapProjects>
): ReturnType<typeof actual.placeAgentMapProjects> => {
packCalls.count += 1
return actual.placeAgentMapProjects(...args)
}
}
})
import { AgentDashboardMapView } from './AgentDashboardMapView'
import { AgentMapTimeRangeField } from './AgentMapTimeRangeField'
const NOW = 2_000_000_000
const MINUTE = 60_000
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
const SLIDER_WIDTH = 280
/** Radix maps pointer x linearly onto [0, AGENT_MAP_TIME_MAX_INDEX]. */
const clientXForStop = (stop: number): number => (stop / AGENT_MAP_TIME_MAX_INDEX) * SLIDER_WIDTH
function card(overrides: Partial<DashboardCard> & { paneKey: string }): DashboardCard {
return {
ptyId: overrides.paneKey,
agentType: 'codex',
bucket: 'working',
dotState: 'working',
task: 'Pack the map',
repoId: 'repo-1',
worktreeId: `worktree-${overrides.paneKey}`,
tabId: 'tab-1',
leafId: `leaf-${overrides.paneKey}`,
repoName: 'Orca',
worktreeName: overrides.paneKey,
startedAt: NOW - MINUTE,
finishedAt: null,
stateChangedAt: NOW - 1_000,
statusUpdatedAt: NOW - 1_000,
unseen: false,
hostKind: 'local',
workspaceKind: 'worktree',
...overrides
}
}
/** One card per stop the drag crosses, each just old enough to be dropped by
* the next step down — so every value change is a real topology change. */
const LIFESPANS: readonly { paneKey: string; lifespan: number }[] = [
{ paneKey: 'agent-20d', lifespan: 20 * DAY },
{ paneKey: 'agent-10d', lifespan: 10 * DAY },
{ paneKey: 'agent-5d', lifespan: 5 * DAY },
{ paneKey: 'agent-2_5d', lifespan: 2.5 * DAY },
{ paneKey: 'agent-36h', lifespan: 36 * HOUR },
{ paneKey: 'agent-18h', lifespan: 18 * HOUR },
{ paneKey: 'agent-5m', lifespan: 5 * MINUTE }
]
const CARDS: DashboardCard[] = LIFESPANS.map(({ paneKey, lifespan }) =>
card({ paneKey, startedAt: NOW - lifespan })
)
const SNAPSHOT: DashboardSnapshot = {
generatedAt: NOW,
cards: CARDS,
workspaces: [],
filterOptions: { projects: [], workspaceStatuses: [] }
}
/** Stops the max thumb passes through on one drag: ∞ → 12h. */
const DRAG_STOPS = [13, 12, 11, 10, 9, 8]
const EXPECTED_DRAG_REPACKS = 1
const DRAFT_CANCELLATIONS = [
{ name: 'pointer cancellation', finish: (thumb: HTMLElement) => fireEvent.pointerCancel(thumb) },
{
name: 'pointer capture loss',
finish: (thumb: HTMLElement) => fireEvent.lostPointerCapture(thumb, { pointerId: 1 })
},
{ name: 'focus loss', finish: (thumb: HTMLElement) => fireEvent.blur(thumb) },
{ name: 'Escape', finish: (thumb: HTMLElement) => fireEvent.keyDown(thumb, { key: 'Escape' }) }
]
function renderMapView(): ReturnType<typeof render> {
return render(
<AgentDashboardMapView
snapshot={SNAPSHOT}
cards={CARDS}
query=""
onQueryChange={vi.fn()}
filters={{ projects: [], workspaceStatuses: [], reviewStates: [] }}
onFiltersChange={vi.fn()}
searchInputRef={{ current: null }}
now={NOW}
dialogCard={null}
onDialogOpenChange={vi.fn()}
onRevealAgent={vi.fn()}
onOpenTerminal={vi.fn()}
workspaceContextMenusEnabled={false}
/>
)
}
async function openTimeSection(): Promise<HTMLElement> {
fireEvent.click(screen.getByRole('button', { name: /^Filter/ }))
fireEvent.click(await screen.findByRole('button', { name: /^Time/ }))
const slider = await screen.findByRole('slider', { name: 'Session lifespan maximum' })
return slider
}
/** Mirrors the panel's wiring: the field is controlled and the owner re-renders
* on every published range. */
function ControlledField({
label,
initial,
onChange
}: {
label: string
initial: AgentMapTimeRange
onChange: (range: AgentMapTimeRange) => void
}): React.JSX.Element {
const [range, setRange] = useState(initial)
return (
<AgentMapTimeRangeField
label={label}
range={range}
onChange={(next) => {
setRange(next)
onChange(next)
}}
/>
)
}
/** Radix reads geometry off the root and gates moves on pointer capture. */
function stubSliderGeometry(): () => void {
const captured = new Set<number>()
const element = Element.prototype as unknown as {
setPointerCapture: (id: number) => void
hasPointerCapture: (id: number) => boolean
releasePointerCapture: (id: number) => void
}
const original = {
setPointerCapture: element.setPointerCapture,
hasPointerCapture: element.hasPointerCapture,
releasePointerCapture: element.releasePointerCapture
}
element.setPointerCapture = (id) => void captured.add(id)
element.hasPointerCapture = (id) => captured.has(id)
element.releasePointerCapture = (id) => void captured.delete(id)
const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: SLIDER_WIDTH,
bottom: 24,
width: SLIDER_WIDTH,
height: 24,
toJSON: () => ({})
})
return () => {
element.setPointerCapture = original.setPointerCapture
element.hasPointerCapture = original.hasPointerCapture
element.releasePointerCapture = original.releasePointerCapture
rect.mockRestore()
}
}
function dragThumb(thumb: HTMLElement, stops: readonly number[], onStep?: () => void): void {
act(() => {
fireEvent.pointerDown(thumb, { pointerId: 1, button: 0, clientX: SLIDER_WIDTH })
})
for (const stop of stops) {
act(() => {
fireEvent.pointerMove(thumb, { pointerId: 1, clientX: clientXForStop(stop) })
})
onStep?.()
}
act(() => {
fireEvent.pointerUp(thumb, { pointerId: 1, clientX: clientXForStop(stops.at(-1) ?? 0) })
})
}
describe('AgentMapTimeRangeField', () => {
let restoreGeometry: () => void
beforeEach(() => {
layoutCalls.updates = 0
layoutCalls.repacks = 0
packCalls.count = 0
restoreGeometry = stubSliderGeometry()
})
afterEach(() => {
restoreGeometry()
cleanup()
vi.restoreAllMocks()
})
it('repacks the whole map once when a multi-stop drag commits', async () => {
renderMapView()
await waitFor(() => expect(document.querySelector('.agent-map-canvas')).toBeTruthy())
const mountRepacks = layoutCalls.repacks
const mountUpdates = layoutCalls.updates
const mountPacks = packCalls.count
const thumb = await openTimeSection()
dragThumb(thumb, DRAG_STOPS)
expect(layoutCalls.repacks - mountRepacks).toBe(EXPECTED_DRAG_REPACKS)
expect(packCalls.count - mountPacks).toBe(EXPECTED_DRAG_REPACKS)
expect(layoutCalls.updates - mountUpdates).toBe(EXPECTED_DRAG_REPACKS)
expect(screen.getByText('of 7 agents shown').parentElement).toHaveTextContent(
'1 of 7 agents shown'
)
await waitFor(() => expect(document.querySelectorAll('[data-agent-map-agent]')).toHaveLength(1))
})
it('updates the thumb and readout at every intermediate drag stop', async () => {
renderMapView()
await waitFor(() => expect(document.querySelector('.agent-map-canvas')).toBeTruthy())
const thumb = await openTimeSection()
const field = thumb.closest('[data-slot="slider"]')?.parentElement as HTMLElement
const readouts: string[] = []
const thumbValues: string[] = []
dragThumb(thumb, DRAG_STOPS, () => {
readouts.push(within(field).getByText(/–|any/).textContent ?? '')
thumbValues.push(thumb.getAttribute('aria-valuenow') ?? '')
})
expect(readouts).toEqual(['0 – 14d', '0 – 7d', '0 – 3d', '0 – 2d', '0 – 1d', '0 – 12h'])
expect(thumbValues).toEqual(DRAG_STOPS.map(String))
})
it('keeps the readout, chip, and map aligned after a full-range collapse', async () => {
renderMapView()
await waitFor(() => expect(document.querySelector('.agent-map-canvas')).toBeTruthy())
const thumb = await openTimeSection()
dragThumb(thumb, [0])
expect(screen.getByText('0 – 0')).toBeInTheDocument()
expect(screen.getByText('Session lifespan: 0–0')).toBeInTheDocument()
expect(screen.getByText('of 7 agents shown').parentElement).toHaveTextContent(
'0 of 7 agents shown'
)
await waitFor(() => expect(document.querySelectorAll('[data-agent-map-agent]')).toHaveLength(0))
})
it('publishes only the final range for a multi-stop drag', () => {
const onChange = vi.fn()
render(
<ControlledField
label="Session lifespan"
initial={{ min: 0, max: AGENT_MAP_TIME_MAX_INDEX }}
onChange={onChange}
/>
)
dragThumb(screen.getByRole('slider', { name: 'Session lifespan maximum' }), DRAG_STOPS)
expect(onChange).toHaveBeenCalledExactlyOnceWith({ min: 0, max: DRAG_STOPS.at(-1) })
})
it.each([
{ name: 'a narrowed range', initial: { min: 5, max: AGENT_MAP_TIME_MAX_INDEX }, stop: 5 },
{ name: 'the full range', initial: { min: 0, max: AGENT_MAP_TIME_MAX_INDEX }, stop: 0 }
])('commits max-thumb collapse from $name', ({ initial, stop }) => {
const onChange = vi.fn()
render(<ControlledField label="Session lifespan" initial={initial} onChange={onChange} />)
dragThumb(screen.getByRole('slider', { name: 'Session lifespan maximum' }), [stop])
expect(onChange).toHaveBeenCalledExactlyOnceWith({ min: stop, max: stop })
expect(
screen.getByText(`${stop === 0 ? '0' : '1h'} – ${stop === 0 ? '0' : '1h'}`)
).toBeInTheDocument()
})
it('follows an external range change while a draft is active', () => {
const onChange = vi.fn()
const field = (range: AgentMapTimeRange): React.JSX.Element => (
<AgentMapTimeRangeField label="Session lifespan" range={range} onChange={onChange} />
)
const view = render(field({ min: 0, max: AGENT_MAP_TIME_MAX_INDEX }))
expect(screen.getByText('any')).toBeInTheDocument()
view.rerender(field({ min: 4, max: 9 }))
expect(screen.getByText('30m – 1d')).toBeInTheDocument()
expect(screen.getByRole('slider', { name: 'Session lifespan minimum' })).toHaveAttribute(
'aria-valuenow',
'4'
)
expect(screen.getByRole('slider', { name: 'Session lifespan maximum' })).toHaveAttribute(
'aria-valuenow',
'9'
)
const thumb = screen.getByRole('slider', { name: 'Session lifespan maximum' })
act(() => {
fireEvent.pointerDown(thumb, { pointerId: 1, button: 0, clientX: clientXForStop(9) })
fireEvent.pointerMove(thumb, { pointerId: 1, clientX: clientXForStop(7) })
})
expect(screen.getByText('30m – 6h')).toBeInTheDocument()
view.rerender(field({ min: 0, max: AGENT_MAP_TIME_MAX_INDEX }))
expect(screen.getByText('any')).toBeInTheDocument()
expect(screen.getByRole('slider', { name: 'Session lifespan maximum' })).toHaveAttribute(
'aria-valuenow',
String(AGENT_MAP_TIME_MAX_INDEX)
)
})
it('does not commit an interaction invalidated by an external range change', () => {
const onChange = vi.fn()
const field = (range: AgentMapTimeRange): React.JSX.Element => (
<AgentMapTimeRangeField label="Session lifespan" range={range} onChange={onChange} />
)
const view = render(field({ min: 0, max: AGENT_MAP_TIME_MAX_INDEX }))
const thumb = screen.getByRole('slider', { name: 'Session lifespan maximum' })
act(() => {
fireEvent.pointerDown(thumb, { pointerId: 1, button: 0, clientX: SLIDER_WIDTH })
fireEvent.pointerMove(thumb, { pointerId: 1, clientX: clientXForStop(8) })
})
view.rerender(field({ min: 4, max: 9 }))
act(() => {
fireEvent.pointerMove(thumb, { pointerId: 1, clientX: clientXForStop(7) })
fireEvent.pointerUp(thumb, { pointerId: 1, clientX: clientXForStop(7) })
})
expect(onChange).not.toHaveBeenCalled()
})
it.each(DRAFT_CANCELLATIONS)('discards a pointer draft on $name', ({ finish }) => {
const onChange = vi.fn()
render(
<AgentMapTimeRangeField
label="Session lifespan"
range={{ min: 0, max: AGENT_MAP_TIME_MAX_INDEX }}
onChange={onChange}
/>
)
const thumb = screen.getByRole('slider', { name: 'Session lifespan maximum' })
act(() => {
fireEvent.pointerDown(thumb, { pointerId: 1, button: 0, clientX: SLIDER_WIDTH })
fireEvent.pointerMove(thumb, { pointerId: 1, clientX: clientXForStop(8) })
})
expect(screen.getByText('0 – 12h')).toBeInTheDocument()
finish(thumb)
expect(screen.getByText('any')).toBeInTheDocument()
expect(onChange).not.toHaveBeenCalled()
})
it('commits a keyboard arrow step', () => {
const onChange = vi.fn()
render(
<AgentMapTimeRangeField
label="Session lifespan"
range={{ min: 0, max: AGENT_MAP_TIME_MAX_INDEX }}
onChange={onChange}
/>
)
const thumb = screen.getByRole('slider', { name: 'Session lifespan maximum' })
// Radix routes arrow keys to the last focused thumb, not the event target.
act(() => thumb.focus())
fireEvent.keyDown(thumb, { key: 'ArrowLeft' })
expect(onChange).toHaveBeenCalledExactlyOnceWith({ min: 0, max: AGENT_MAP_TIME_MAX_INDEX - 1 })
})
})
@@ -1,135 +0,0 @@
import { Slider } from '@/components/ui/slider'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { useRef, useState } from 'react'
import {
AGENT_MAP_TIME_MAX_INDEX,
agentMapTimeStopLabel,
isFullAgentMapTimeRange,
type AgentMapTimeRange
} from './agent-map-time-filter'
type AgentMapTimeRangeFieldProps = {
label: string
range: AgentMapTimeRange
onChange: (range: AgentMapTimeRange) => void
}
type SliderInteraction = {
source: AgentMapTimeRange
value: AgentMapTimeRange | null
}
/** Ticks are sparse on purpose — the scale is non-linear, so labelling every
* stop would read as evenly spaced time when it is not. */
const TICKS = [0, 5, 9, 12, AGENT_MAP_TIME_MAX_INDEX]
const SLIDER_KEYBOARD_COMMIT_KEYS = [
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'End',
'Home',
'PageDown',
'PageUp'
]
export function AgentMapTimeRangeField({
label,
range,
onChange
}: AgentMapTimeRangeFieldProps): React.JSX.Element {
const [draft, setDraft] = useState<{
source: AgentMapTimeRange
value: AgentMapTimeRange
} | null>(null)
const interaction = useRef<SliderInteraction | null>(null)
const reconcileInteraction = (value?: AgentMapTimeRange): void => {
const source = interaction.current?.source
interaction.current = null
setDraft(null)
if (value && source === range) {
onChange(value)
}
}
// New external range objects invalidate stale drafts from resets and quick views.
const displayedRange = draft?.source === range ? draft.value : range
const isFull = isFullAgentMapTimeRange(displayedRange)
return (
<div className="px-1.5 pt-1 pb-2">
<div className="flex items-baseline gap-2 text-xs">
<span>{label}</span>
<span
className={cn(
'ml-auto text-[11px] tabular-nums',
isFull ? 'text-muted-foreground' : 'text-foreground'
)}
>
{isFull
? translate('dashboardPopout.map.filters.timeAny', 'any')
: `${agentMapTimeStopLabel(displayedRange.min)} – ${agentMapTimeStopLabel(displayedRange.max)}`}
</span>
</div>
<Slider
className="mt-2"
aria-label={label}
min={0}
max={AGENT_MAP_TIME_MAX_INDEX}
step={1}
minStepsBetweenThumbs={0}
value={[displayedRange.min, displayedRange.max]}
thumbLabels={[
translate('dashboardPopout.map.filters.timeMinimum', '{{label}} minimum', { label }),
translate('dashboardPopout.map.filters.timeMaximum', '{{label}} maximum', { label })
]}
thumbValueLabels={[
agentMapTimeStopLabel(displayedRange.min),
agentMapTimeStopLabel(displayedRange.max)
]}
onKeyDown={(event) => {
if (event.key === 'Escape') {
reconcileInteraction()
return
}
if (SLIDER_KEYBOARD_COMMIT_KEYS.includes(event.key)) {
interaction.current = { source: range, value: null }
}
}}
onPointerDown={() => {
interaction.current = { source: range, value: null }
}}
onPointerUp={() => {
reconcileInteraction(interaction.current?.value ?? undefined)
}}
onPointerCancel={() => {
reconcileInteraction()
}}
onLostPointerCapture={() => {
reconcileInteraction()
}}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
reconcileInteraction()
}
}}
onValueChange={([min, max]) => {
const current = interaction.current
if (!current) {
return
}
const value = { min, max }
current.value = value
setDraft({ source: current.source, value })
}}
onValueCommit={([min, max]) => {
reconcileInteraction({ min, max })
}}
/>
<div className="mt-1 flex justify-between text-[9px] text-muted-foreground">
{TICKS.map((tick) => (
<span key={tick}>{agentMapTimeStopLabel(tick)}</span>
))}
</div>
</div>
)
}
@@ -1,47 +0,0 @@
import { Focus, Minus, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
type AgentMapViewportControlsProps = {
zoom: number
onFit: () => void
onZoomIn: () => void
onZoomOut: () => void
}
export function AgentMapViewportControls({
zoom,
onFit,
onZoomIn,
onZoomOut
}: AgentMapViewportControlsProps): React.JSX.Element {
return (
<div className="absolute bottom-3 left-3 flex items-center gap-0.5 rounded-md border border-border bg-card p-0.5 shadow-xs">
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate('dashboardPopout.map.zoomOut', 'Zoom out')}
onClick={onZoomOut}
>
<Minus className="size-3" />
</Button>
<span className="min-w-12 text-center text-[10px] text-muted-foreground tabular-nums">
{Math.round(zoom * 100)}%
</span>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate('dashboardPopout.map.zoomIn', 'Zoom in')}
onClick={onZoomIn}
>
<Plus className="size-3" />
</Button>
<Button type="button" variant="ghost" size="xs" onClick={onFit}>
<Focus className="size-3" />
{translate('dashboardPopout.map.fit', 'Fit')}
</Button>
</div>
)
}
@@ -1,35 +0,0 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
function source(file: string): string {
return readFileSync(
resolve(process.cwd(), 'src/renderer/src/components/dashboard-popout', file),
'utf8'
)
}
describe('Agent Map workspace menu performance boundary', () => {
it('loads store-backed workspace actions only after a ring context request', () => {
const loader = source('AgentMapWorkspaceContextMenuLoader.tsx')
const menu = source('AgentMapWorkspaceContextMenu.tsx')
expect(loader).toMatch(/import\('\.\/AgentMapWorkspaceContextMenu'\)/)
expect(loader).not.toMatch(
/import\s+\{\s*AgentMapWorkspaceContextMenu\s*\}\s+from\s+['"]\.\/AgentMapWorkspaceContextMenu['"]/
)
expect(menu).toMatch(/import\('@\/components\/sidebar\/WorktreeContextMenu'\)/)
expect(menu).not.toMatch(
/import\s+WorktreeContextMenu\s+from\s+['"]@\/components\/sidebar\/WorktreeContextMenu['"]/
)
})
it('loads store-backed project actions only after a project context request', () => {
const loader = source('AgentMapProjectContextMenuLoader.tsx')
expect(loader).toMatch(/import\('\.\/AgentMapProjectContextMenu'\)/)
expect(loader).not.toMatch(
/import\s+\{\s*AgentMapProjectContextMenu\s*\}\s+from\s+['"]\.\/AgentMapProjectContextMenu['"]/
)
})
})
@@ -1,389 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useAppStore } from '@/store'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { AgentMap } from './AgentMap'
import * as StoreSelectors from '@/store/selectors'
const NOW = 2_000_000_000
const EXECUTION_HOST_ID = 'runtime:env-1' as const
const initialState = useAppStore.getState()
const repo = {
id: 'repo-1',
path: '/repo',
displayName: 'Orca',
badgeColor: '#000000',
addedAt: NOW,
kind: 'git',
executionHostId: EXECUTION_HOST_ID
} satisfies Repo
const worktree = {
id: 'worktree-1',
repoId: repo.id,
path: '/repo/worktrees/map',
displayName: 'Agent map',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
branch: 'refs/heads/agent-map',
head: 'abc123',
isBare: false,
isMainWorktree: false,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: NOW,
hostId: EXECUTION_HOST_ID
} satisfies Worktree
const collidingLocalWorktree = {
...worktree,
path: '/local/repo/worktrees/map',
displayName: 'Local agent map',
hostId: 'local'
} satisfies Worktree
const parentWorktree = {
...worktree,
id: 'worktree-parent',
path: '/repo/worktrees/parent',
displayName: 'Parent worktree',
branch: 'refs/heads/parent'
} satisfies Worktree
const folderProjectGroup = {
id: 'group-1',
name: 'Documentation',
parentPath: '/docs',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: NOW,
updatedAt: NOW
} satisfies ProjectGroup
const card: DashboardCard = {
paneKey: 'pane-1',
ptyId: 'pty-1',
agentType: 'codex',
bucket: 'working',
dotState: 'working',
task: 'Build map',
repoId: repo.id,
worktreeId: worktree.id,
tabId: 'tab-1',
leafId: 'leaf-1',
repoName: repo.displayName,
worktreeName: worktree.displayName,
startedAt: NOW - 60_000,
finishedAt: null,
stateChangedAt: NOW - 1_000,
unseen: false,
workspaceKind: 'worktree'
}
describe('Agent Map workspace context menu', () => {
beforeEach(() => {
useAppStore.setState({
repos: [repo],
worktreesByRepo: { [repo.id]: [worktree, parentWorktree] },
detectedWorktreesByRepo: {},
projectGroups: [],
workspaceStatuses: [{ id: 'todo', label: 'Todo' }]
})
})
afterEach(() => {
cleanup()
useAppStore.setState(initialState, true)
vi.restoreAllMocks()
})
it('opens the shared sidebar workspace actions from a worktree ring', async () => {
const useWorktreeById = vi.spyOn(StoreSelectors, 'useWorktreeById')
render(
<TooltipProvider>
<AgentMap
cards={[{ ...card, executionHostId: EXECUTION_HOST_ID }]}
now={NOW}
workspaceContextMenusEnabled
onOpenTerminal={() => {}}
/>
</TooltipProvider>
)
fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' }), {
clientX: 120,
clientY: 140
})
expect(await screen.findByText('Workspace', {}, { timeout: 5_000 })).toBeInTheDocument()
expect(screen.getByText('Update')).toBeInTheDocument()
expect(screen.getByText('Move to Status')).toBeInTheDocument()
expect(screen.getByText('Open in')).toBeInTheDocument()
expect(screen.getByText('Copy Path')).toBeInTheDocument()
expect(screen.getByText('Pin')).toBeInTheDocument()
expect(screen.getByText('Mark Unread')).toBeInTheDocument()
expect(screen.getByText('Sleep')).toBeInTheDocument()
expect(screen.getByText('Delete')).toBeInTheDocument()
expect(useWorktreeById).toHaveBeenCalledWith(worktree.id, EXECUTION_HOST_ID)
})
it('deduplicates the same host owner across known and detected worktrees', async () => {
useAppStore.setState({
detectedWorktreesByRepo: {
[repo.id]: {
repoId: repo.id,
authoritative: true,
source: 'git',
worktrees: [
{ ...worktree, ownership: 'orca-managed', selectedCheckout: false, visible: true }
]
}
}
})
render(
<TooltipProvider>
<AgentMap
cards={[{ ...card, executionHostId: EXECUTION_HOST_ID }]}
now={NOW}
workspaceContextMenusEnabled
onOpenTerminal={() => {}}
/>
</TooltipProvider>
)
fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' }))
expect(await screen.findByText('Workspace', {}, { timeout: 5_000 })).toBeInTheDocument()
})
it('uses explicit SSH ownership instead of the paired hub repo host', async () => {
const sshHostId = 'ssh:provider-1' as const
const sshWorktree = { ...worktree, hostId: sshHostId }
useAppStore.setState({ worktreesByRepo: { [repo.id]: [sshWorktree] } })
render(
<TooltipProvider>
<AgentMap
cards={[{ ...card, executionHostId: sshHostId }]}
now={NOW}
workspaceContextMenusEnabled
onOpenTerminal={() => {}}
/>
</TooltipProvider>
)
fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' }))
expect(await screen.findByText('Workspace', {}, { timeout: 5_000 })).toBeInTheDocument()
})
it('fails closed when bare-ID actions would span multiple execution hosts', async () => {
useAppStore.setState({
worktreesByRepo: { [repo.id]: [collidingLocalWorktree, worktree] }
})
const useWorktreeById = vi.spyOn(StoreSelectors, 'useWorktreeById')
render(
<TooltipProvider>
<AgentMap
cards={[{ ...card, executionHostId: EXECUTION_HOST_ID }]}
now={NOW}
workspaceContextMenusEnabled
onOpenTerminal={() => {}}
/>
</TooltipProvider>
)
fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' }))
await waitFor(() =>
expect(useWorktreeById).toHaveBeenCalledWith(worktree.id, EXECUTION_HOST_ID)
)
await act(async () => new Promise((resolve) => window.setTimeout(resolve, 0)))
expect(screen.queryByText('Workspace')).not.toBeInTheDocument()
act(() => {
useAppStore.setState({ worktreesByRepo: { [repo.id]: [worktree] } })
})
expect(screen.queryByText('Workspace')).not.toBeInTheDocument()
})
it('clears a workspace request whose target disappeared', async () => {
useAppStore.setState({ worktreesByRepo: {} })
const useWorktreeById = vi.spyOn(StoreSelectors, 'useWorktreeById')
render(
<TooltipProvider>
<AgentMap
cards={[{ ...card, executionHostId: EXECUTION_HOST_ID }]}
now={NOW}
workspaceContextMenusEnabled
onOpenTerminal={() => {}}
/>
</TooltipProvider>
)
fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' }))
await waitFor(() => expect(useWorktreeById).toHaveBeenCalled())
await act(async () => new Promise((resolve) => window.setTimeout(resolve, 0)))
act(() => {
useAppStore.setState({ worktreesByRepo: { [repo.id]: [worktree] } })
})
expect(screen.queryByText('Workspace')).not.toBeInTheDocument()
})
it('releases the store-backed workspace menu after an ordinary close', async () => {
const getKnownWorktreeById = vi.fn(useAppStore.getState().getKnownWorktreeById)
useAppStore.setState({ getKnownWorktreeById })
render(
<TooltipProvider>
<AgentMap
cards={[{ ...card, executionHostId: EXECUTION_HOST_ID }]}
now={NOW}
workspaceContextMenusEnabled
onOpenTerminal={() => {}}
/>
</TooltipProvider>
)
fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' }))
expect(await screen.findByText('Workspace', {}, { timeout: 5_000 })).toBeInTheDocument()
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => expect(screen.queryByText('Workspace')).not.toBeInTheDocument())
await act(async () => new Promise((resolve) => window.setTimeout(resolve, 0)))
getKnownWorktreeById.mockClear()
act(() => {
useAppStore.setState({ agentStatusEpoch: useAppStore.getState().agentStatusEpoch + 1 })
})
expect(getKnownWorktreeById).not.toHaveBeenCalled()
})
it('keeps shared-menu follow-up overlays mounted through their lifecycle', async () => {
render(
<TooltipProvider>
<AgentMap
cards={[{ ...card, executionHostId: EXECUTION_HOST_ID }]}
now={NOW}
workspaceContextMenusEnabled
onOpenTerminal={() => {}}
/>
</TooltipProvider>
)
const ring = screen.getByRole('button', { name: 'Open Agent map worktree details' })
fireEvent.contextMenu(ring)
const createGroup = await screen.findByText('New group from project', {}, { timeout: 5_000 })
fireEvent.pointerDown(createGroup, { button: 0 })
fireEvent.click(createGroup)
expect(await screen.findByRole('dialog', { name: 'New Project Group' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: 'New Project Group' })).not.toBeInTheDocument()
)
fireEvent.contextMenu(ring)
const setParent = await screen.findByText('Set Parent Worktree...', {}, { timeout: 5_000 })
fireEvent.pointerDown(setParent, { button: 0 })
fireEvent.click(setParent)
// Candidate rows are virtualized and measure 0 in happy-dom; the mounted
// search input is the picker's lifecycle signal.
expect(await screen.findByPlaceholderText('Search worktrees...')).toBeInTheDocument()
})
it('opens the existing worktree composer from a project ring', async () => {
const { container } = render(
<TooltipProvider>
<AgentMap cards={[card]} now={NOW} workspaceContextMenusEnabled onOpenTerminal={() => {}} />
</TooltipProvider>
)
fireEvent.contextMenu(container.querySelector('[data-agent-map-project]')!, {
clientX: 100,
clientY: 110
})
const createWorktree = await screen.findByText(
'Create new worktree for Orca',
{},
{ timeout: 5_000 }
)
// Radix restores focus after unmount; drain it before the next test opens a menu.
const focusRestored = new Promise<void>((resolve) => {
screen
.getByRole('menu')
.addEventListener('focusScope.autoFocusOnUnmount', () => resolve(), { once: true })
})
fireEvent.click(createWorktree)
await act(async () => focusRestored)
expect(useAppStore.getState().activeModal).toBe('new-workspace-composer')
expect(useAppStore.getState().modalData).toEqual({
initialRepoId: repo.id,
telemetrySource: 'sidebar'
})
})
it('opens the folder-workspace composer from a synthetic project ring', async () => {
useAppStore.setState({ projectGroups: [folderProjectGroup] })
const folderCard = {
...card,
repoId: `folder-workspace:${folderProjectGroup.id}`,
repoName: folderProjectGroup.name,
worktreeId: 'folder:folder-1',
worktreeName: 'Docs',
workspaceKind: 'folder' as const
}
const { container } = render(
<TooltipProvider>
<AgentMap
cards={[folderCard]}
now={NOW}
workspaceContextMenusEnabled
onOpenTerminal={() => {}}
/>
</TooltipProvider>
)
fireEvent.contextMenu(container.querySelector('[data-agent-map-project]')!)
fireEvent.click(
await screen.findByText('Create workspace for Documentation', {}, { timeout: 5_000 })
)
expect(useAppStore.getState().modalData).toEqual({
initialProjectGroupId: folderProjectGroup.id,
telemetrySource: 'sidebar'
})
})
it('clears an ambiguous project request instead of choosing a repo host', async () => {
useAppStore.setState({
repos: [repo, { ...repo, path: '/local/repo', executionHostId: 'local' }]
})
const { container } = render(
<TooltipProvider>
<AgentMap cards={[card]} now={NOW} workspaceContextMenusEnabled onOpenTerminal={() => {}} />
</TooltipProvider>
)
fireEvent.contextMenu(container.querySelector('[data-agent-map-project]')!)
await act(async () => new Promise((resolve) => window.setTimeout(resolve, 0)))
expect(screen.queryByText('Create new worktree for Orca')).not.toBeInTheDocument()
act(() => {
useAppStore.setState({ repos: [repo] })
})
expect(screen.queryByText('Create new worktree for Orca')).not.toBeInTheDocument()
})
})
@@ -1,167 +0,0 @@
import { Suspense, useEffect, useMemo, useRef } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { lazyWithRetry } from '@/lib/lazy-with-retry'
import { useAppStore } from '@/store'
import { useWorktreeById } from '@/store/selectors'
import type { AppState } from '@/store/types'
import {
getRepoExecutionHostId,
normalizeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
const WorktreeContextMenu = lazyWithRetry(
() => import('@/components/sidebar/WorktreeContextMenu'),
{ reloadKey: 'agent-map-worktree-context-menu' }
)
export type AgentMapWorkspaceContextMenuRequest = {
id: number
worktreeId: string
executionHostId?: ExecutionHostId
clientX: number
clientY: number
altKey: boolean
}
type AgentMapWorkspaceContextMenuProps = {
request: AgentMapWorkspaceContextMenuRequest | null
onOpenChange?: (open: boolean) => void
onLifecycleComplete?: () => void
}
function countWorkspaceOwners(
worktreeId: string | null,
state: Pick<
AppState,
'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces' | 'repos'
>
): number {
if (!worktreeId) {
return 0
}
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
return new Set(
state.folderWorkspaces
.filter((workspace) => workspace.id === scope.folderWorkspaceId)
.map(
(workspace) =>
normalizeExecutionHostId(workspace.executionHostId) ??
(workspace.connectionId ? toSshExecutionHostId(workspace.connectionId) : 'local')
)
).size
}
const repoOwnerIdsByRepoId = new Map<string, Set<ExecutionHostId>>()
for (const repo of state.repos) {
const ownerId = getRepoExecutionHostId(repo)
const owners = repoOwnerIdsByRepoId.get(repo.id)
if (owners) {
owners.add(ownerId)
} else {
repoOwnerIdsByRepoId.set(repo.id, new Set([ownerId]))
}
}
const ownerIds = new Set<ExecutionHostId>()
const addOwner = (worktree: { repoId: string; hostId?: ExecutionHostId }): void => {
const directOwner = normalizeExecutionHostId(worktree.hostId)
if (directOwner) {
ownerIds.add(directOwner)
return
}
const repoOwnerIds = repoOwnerIdsByRepoId.get(worktree.repoId)
if (!repoOwnerIds) {
ownerIds.add('local')
return
}
for (const ownerId of repoOwnerIds) {
ownerIds.add(ownerId)
}
}
for (const worktrees of Object.values(state.worktreesByRepo)) {
for (const worktree of worktrees) {
if (worktree.id === worktreeId) {
addOwner(worktree)
}
}
}
for (const result of Object.values(state.detectedWorktreesByRepo)) {
for (const worktree of result.worktrees) {
if (worktree.id === worktreeId) {
addOwner(worktree)
}
}
}
return ownerIds.size
}
function ContextMenuTrigger({
request
}: {
request: AgentMapWorkspaceContextMenuRequest
}): React.JSX.Element {
const triggerRef = useRef<HTMLSpanElement>(null)
useEffect(() => {
triggerRef.current?.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: request.clientX,
clientY: request.clientY,
altKey: request.altKey,
button: 2
})
)
}, [request])
return <span ref={triggerRef} aria-hidden />
}
export function AgentMapWorkspaceContextMenu({
request,
onOpenChange,
onLifecycleComplete
}: AgentMapWorkspaceContextMenuProps): React.JSX.Element | null {
const { worktreesByRepo, detectedWorktreesByRepo, folderWorkspaces, repos } = useAppStore(
useShallow((state) => ({
worktreesByRepo: state.worktreesByRepo,
detectedWorktreesByRepo: state.detectedWorktreesByRepo,
folderWorkspaces: state.folderWorkspaces,
repos: state.repos
}))
)
const worktree = useWorktreeById(request?.worktreeId ?? null, request?.executionHostId)
const ownerCount = useMemo(
() =>
countWorkspaceOwners(request?.worktreeId ?? null, {
worktreesByRepo,
detectedWorktreesByRepo,
folderWorkspaces,
repos
}),
[detectedWorktreesByRepo, folderWorkspaces, repos, request?.worktreeId, worktreesByRepo]
)
const unavailable = request !== null && (!worktree || ownerCount !== 1)
useEffect(() => {
if (unavailable) {
onLifecycleComplete?.()
}
}, [onLifecycleComplete, unavailable])
if (!request || unavailable || !worktree) {
return null
}
return (
<div className="pointer-events-none absolute inset-0">
<Suspense fallback={null}>
<WorktreeContextMenu
worktree={worktree}
onOpenChange={onOpenChange}
onLifecycleComplete={onLifecycleComplete}
>
<ContextMenuTrigger request={request} />
</WorktreeContextMenu>
</Suspense>
</div>
)
}
@@ -1,35 +0,0 @@
import { Suspense } from 'react'
import { lazyWithRetry } from '@/lib/lazy-with-retry'
import type { AgentMapWorkspaceContextMenuRequest } from './AgentMapWorkspaceContextMenu'
const AgentMapWorkspaceContextMenu = lazyWithRetry(
() =>
import('./AgentMapWorkspaceContextMenu').then((module) => ({
default: module.AgentMapWorkspaceContextMenu
})),
{ reloadKey: 'agent-map-workspace-context-menu' }
)
type AgentMapWorkspaceContextMenuLoaderProps = {
request: AgentMapWorkspaceContextMenuRequest
onOpenChange?: (open: boolean) => void
onLifecycleComplete?: () => void
}
export function AgentMapWorkspaceContextMenuLoader({
request,
onOpenChange,
onLifecycleComplete
}: AgentMapWorkspaceContextMenuLoaderProps): React.JSX.Element {
return (
<Suspense fallback={null}>
<AgentMapWorkspaceContextMenu
request={request}
onOpenChange={onOpenChange}
onLifecycleComplete={onLifecycleComplete}
/>
</Suspense>
)
}
export type { AgentMapWorkspaceContextMenuRequest }
@@ -1,40 +0,0 @@
import { memo } from 'react'
import { translate } from '@/i18n/i18n'
import type { AgentMapWorktreeRing } from './agent-map-layout'
type AgentMapWorktreeLabelProps = {
worktree: AgentMapWorktreeRing
visible: boolean
active: boolean
labelScale: number
mapScale: number
}
export const AgentMapWorktreeLabel = memo(function AgentMapWorktreeLabel({
worktree,
visible,
active,
labelScale,
mapScale
}: AgentMapWorktreeLabelProps): React.JSX.Element {
// Hover is an explicit ask for this workspace's detail, so it outranks declutter.
const showCount = active || (visible && worktree.radius * mapScale >= 80)
const agentCountText = translate(
'dashboardPopout.map.agentCount',
worktree.agents.length === 1 ? '{{count}} agent' : '{{count}} agents',
{ count: worktree.agents.length }
)
return (
<g
className={`agent-map-worktree-label-group${visible ? ' is-visible' : ''}${active ? ' is-active' : ''}${showCount ? ' is-count-visible' : ''}${worktree.motionState ? ` is-${worktree.motionState}` : ''}`}
transform={`translate(${worktree.x} ${worktree.y - worktree.radius}) scale(${labelScale})`}
>
<text className="agent-map-worktree-label" y={18}>
{worktree.name}
</text>
<text className="agent-map-worktree-count" y={32}>
{agentCountText}
</text>
</g>
)
})
@@ -1,413 +0,0 @@
import { memo, useState, type MutableRefObject } from 'react'
import { Plus } from 'lucide-react'
import { AgentStateDot } from '@/components/AgentStateDot'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { translate } from '@/i18n/i18n'
import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog'
import { agentTypeToIconAgent } from '@/lib/agent-status'
import type { DashboardCard, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type {
AgentMapAgentNode,
AgentMapProjectRing,
AgentMapWorktreeRing
} from './agent-map-layout'
import { AGENT_MAP_LINEAGE_RELATION, shouldAggregateAgentMapWorktree } from './agent-map-layout'
import { AgentMapQuestionMarker } from './AgentMapQuestionMarker'
import type { AgentMapFlareStatus } from './agent-map-node-metadata'
import {
agentMapAttentionMarkerScale,
agentMapStatusLabel,
agentName,
formatDuration,
lineagePath
} from './agent-map-node-presentation'
import { agentMapWorktreeActiveStatus } from './agent-map-worktree-active-status'
type AgentMapWorktreeRingNodeProps = {
project: AgentMapProjectRing
worktree: AgentMapWorktreeRing
zoom: number
mapScale: number
/** Pressed at the start of a pan drag; keeps the ring lit through the gesture. */
held: boolean
selectedPaneKey: string | null
allowAggregation: boolean
showOrchestrationLinks: boolean
recentFlareStatuses: ReadonlyMap<string, AgentMapFlareStatus>
launchableAgents?: readonly TuiAgent[]
nodeRefs: MutableRefObject<Map<string, SVGGElement>>
onSelectAgent: (card: DashboardCard) => void
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
onOpenWorkspaceContextMenu?: (
event: React.MouseEvent<SVGCircleElement>,
worktree: AgentMapWorktreeRing
) => void
onLabelHoverChange: (worktreeId: string, active: boolean) => void
onLabelFocusChange: (worktreeId: string, active: boolean) => void
onAgentKeyDown: (event: React.KeyboardEvent<SVGGElement>, agent: AgentMapAgentNode) => void
}
function WorktreeDetails({
project,
worktree,
launchableAgents,
onSelectAgent,
onSpawnAgent,
onDone
}: Pick<
AgentMapWorktreeRingNodeProps,
'project' | 'worktree' | 'launchableAgents' | 'onSelectAgent' | 'onSpawnAgent'
> & {
onDone: () => void
}): React.JSX.Element {
const activeCount =
worktree.statusCounts.working +
worktree.statusCounts.monitoring +
worktree.statusCounts.blocked +
worktree.statusCounts.waiting
const doneCount = worktree.statusCounts.done + worktree.statusCounts['done-seen']
return (
<PopoverContent align="center" sideOffset={10} className="w-80 p-0">
<header className="border-b border-border px-3 py-2.5">
<span className="block truncate text-[11px] text-muted-foreground">{project.name}</span>
<strong className="block truncate text-[13px]">{worktree.name}</strong>
<span className="mt-1 block text-[11px] text-muted-foreground">
{translate(
'dashboardPopout.map.worktreeSummary',
'{{total}} agents · {{active}} active · {{done}} done',
{
count: worktree.agents.length,
defaultValue_one: '{{total}} agent · {{active}} active · {{done}} done',
defaultValue_other: '{{total}} agents · {{active}} active · {{done}} done',
total: worktree.agents.length,
active: activeCount,
done: doneCount
}
)}
</span>
</header>
<section className={onSpawnAgent ? 'border-b border-border px-2 py-2' : 'px-2 py-2'}>
<h3 className="mb-1 px-1 text-[11px] font-semibold text-muted-foreground">
{translate('dashboardPopout.map.runningAgents', 'Agents')}
</h3>
<div className="scrollbar-sleek max-h-56 space-y-0.5 overflow-y-auto">
{worktree.agents.length === 0 ? (
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">
{translate('dashboardPopout.map.noWorkspaceAgents', 'No agents in this workspace.')}
</p>
) : (
worktree.agents.map((agent) => (
<button
key={agent.card.paneKey}
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
onClick={() => {
onSelectAgent(agent.card)
onDone()
}}
>
<AgentIcon agent={agentTypeToIconAgent(agent.card.agentType)} size={14} />
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] font-medium">
{agentName(agent.card)}
</span>
<span className="block truncate text-[11px] text-muted-foreground">
{agentMapStatusLabel(agent.status)} · {formatDuration(agent.durationMinutes)}
</span>
</span>
<AgentStateDot
state={agent.status === 'done-seen' ? 'done' : agent.status}
size="md"
/>
</button>
))
)}
</div>
</section>
{onSpawnAgent ? (
<section className="px-3 py-2.5">
<h3 className="mb-1.5 text-[11px] font-semibold text-muted-foreground">
{translate('dashboardPopout.map.spawnAgent', 'Start a new agent')}
</h3>
{launchableAgents && launchableAgents.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{launchableAgents.map((agent) => (
<Button
key={agent}
type="button"
variant="outline"
size="xs"
className="gap-1.5"
onClick={() => {
onSpawnAgent({ worktreeId: worktree.worktreeId, agent })
onDone()
}}
>
<Plus className="size-3" />
<AgentIcon agent={agent} size={12} />
{getAgentLabel(agent)}
</Button>
))}
</div>
) : (
<p className="text-[11px] text-muted-foreground">
{translate('dashboardPopout.map.noLaunchableAgents', 'No enabled agents detected.')}
</p>
)}
</section>
) : null}
</PopoverContent>
)
}
export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({
project,
worktree,
zoom,
mapScale,
held,
selectedPaneKey,
allowAggregation,
showOrchestrationLinks,
recentFlareStatuses,
launchableAgents,
nodeRefs,
onSelectAgent,
onSpawnAgent,
onOpenWorkspaceContextMenu,
onLabelHoverChange,
onLabelFocusChange,
onAgentKeyDown
}: AgentMapWorktreeRingNodeProps): React.JSX.Element {
const [detailsOpen, setDetailsOpen] = useState(false)
const exiting = project.motionState === 'exiting' || worktree.motionState === 'exiting'
const selected = worktree.agents.some((agent) => agent.card.paneKey === selectedPaneKey)
const activeStatus = agentMapWorktreeActiveStatus(worktree.statusCounts)
const aggregate = !selected && shouldAggregateAgentMapWorktree(worktree, zoom, allowAggregation)
const agentsByPaneKey = new Map(worktree.agents.map((agent) => [agent.card.paneKey, agent]))
return (
<Popover open={detailsOpen && !exiting} onOpenChange={setDetailsOpen}>
<g
className={`agent-map-worktree-group${worktree.motionState ? ` is-${worktree.motionState}` : ''}${held ? ' is-held' : ''}`}
data-agent-map-worktree-id={worktree.id}
aria-hidden={exiting || undefined}
onPointerEnter={() => onLabelHoverChange(worktree.id, true)}
onPointerLeave={() => onLabelHoverChange(worktree.id, false)}
onFocus={() => onLabelFocusChange(worktree.id, true)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
onLabelFocusChange(worktree.id, false)
}
}}
>
{activeStatus ? (
<circle
className={`agent-map-worktree-status-glow fleet-status-${activeStatus}`}
data-agent-map-worktree-status-glow=""
data-worktree-active-status={activeStatus}
cx={worktree.x}
cy={worktree.y}
r={worktree.radius}
aria-hidden="true"
/>
) : null}
<PopoverTrigger asChild>
<circle
className={`agent-map-worktree-ring${activeStatus ? ` is-${activeStatus}` : ''}${selected ? ' is-selected' : ''}${detailsOpen ? ' is-open' : ''}`}
data-agent-map-worktree=""
data-agent-count={worktree.agents.length}
cx={worktree.x}
cy={worktree.y}
r={worktree.radius}
role="button"
tabIndex={exiting ? -1 : 0}
aria-hidden={exiting || undefined}
aria-label={
worktree.workspaceKind === 'folder'
? translate(
'dashboardPopout.map.openFolderWorkspace',
'Open {{workspace}} folder workspace details',
{ workspace: worktree.name }
)
: translate(
'dashboardPopout.map.openWorktree',
'Open {{worktree}} worktree details',
{ worktree: worktree.name }
)
}
onKeyDown={(event) => {
if (exiting) {
return
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setDetailsOpen((open) => !open)
}
}}
onContextMenu={
onOpenWorkspaceContextMenu && !exiting
? (event) => {
event.preventDefault()
event.stopPropagation()
setDetailsOpen(false)
onOpenWorkspaceContextMenu(event, worktree)
}
: undefined
}
/>
</PopoverTrigger>
{aggregate ? (
<g
className="agent-map-aggregate-node"
transform={`translate(${worktree.x} ${worktree.y + 7})`}
>
<circle r={Math.min(26, 12 + Math.sqrt(worktree.agents.length) * 2)} />
<text y={3}>{worktree.agents.length}</text>
</g>
) : (
<>
<g className="agent-map-lineage-links" aria-hidden>
{(showOrchestrationLinks ? worktree.agents : []).map((child) => {
const parent = child.card.parentPaneKey
? agentsByPaneKey.get(child.card.parentPaneKey)
: undefined
// 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
}
return (
<path
key={child.card.paneKey}
className={`agent-map-lineage-link${child.motionState === 'exiting' || parent.motionState === 'exiting' ? ' is-exiting' : child.motionState === 'entering' || parent.motionState === 'entering' ? ' is-entering' : ''}`}
data-agent-map-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={lineagePath(parent, child)}
/>
)
})}
</g>
{worktree.agents.map((agent) => {
const iconSize = Math.max(12, Math.min(22, agent.radius * 1.05))
const agentExiting = exiting || agent.motionState === 'exiting'
const flareStatus = recentFlareStatuses.get(agent.card.paneKey)
// `done` here is the unread finish only — `done-seen` demotes to a bare
// emerald ring so the halo keeps meaning "this one is still unread".
const hasStatusGlow =
agent.status === 'working' ||
agent.status === 'waiting' ||
agent.status === 'blocked' ||
agent.status === 'done'
return (
<g
key={agent.card.paneKey}
ref={(node) => {
if (node) {
nodeRefs.current.set(agent.card.paneKey, node)
} else {
nodeRefs.current.delete(agent.card.paneKey)
}
}}
data-agent-map-agent=""
data-agent-provider={agent.card.agentType}
role="button"
tabIndex={agentExiting ? -1 : 0}
aria-hidden={agentExiting || undefined}
aria-pressed={selectedPaneKey === agent.card.paneKey}
aria-label={`${agentName(agent.card)}, ${agentMapStatusLabel(agent.status)}${agent.card.unseen ? ', unread' : ''}, ${formatDuration(agent.durationMinutes)}, ${worktree.name}, ${project.name}`}
className={`agent-map-agent-node fleet-status-${agent.status}${selectedPaneKey === agent.card.paneKey ? ' is-selected' : ''}${agent.motionState ? ` is-${agent.motionState}` : ''}`}
transform={`translate(${agent.x} ${agent.y})`}
onClick={(event) => {
if (agentExiting) {
return
}
event.currentTarget.focus()
onSelectAgent(agent.card)
}}
onKeyDown={(event) => {
if (!agentExiting) {
onAgentKeyDown(event, agent)
}
}}
>
<g className="agent-map-agent-visual">
{hasStatusGlow ? (
<circle
className={`agent-map-agent-status-glow fleet-status-${agent.status}`}
data-agent-map-agent-status-glow=""
data-agent-active-status={agent.status}
r={agent.radius + 1}
aria-hidden="true"
/>
) : null}
{/* One-shot ripple for fresh questions and finishes. */}
{flareStatus && !agentExiting ? (
<circle
className={`agent-map-agent-status-flare fleet-status-${flareStatus}`}
data-agent-map-agent-status-flare=""
r={agent.radius + 1}
aria-hidden="true"
/>
) : null}
<circle className="agent-map-agent-hit" r={Math.max(10, agent.radius + 3)} />
<circle className="agent-map-agent-mark" r={agent.radius} />
<foreignObject
className="agent-map-agent-icon"
x={-iconSize / 2}
y={-iconSize / 2}
width={iconSize}
height={iconSize}
>
<div>
<AgentIcon
agent={agentTypeToIconAgent(agent.card.agentType)}
size={iconSize}
/>
</div>
</foreignObject>
{/* Unread dot sits ON the ring stroke; its halo breaks the ring around it. */}
{agent.card.unseen ? (
<circle
className="agent-map-agent-unread-mark"
data-agent-unread-marker=""
cx={-agent.radius * Math.SQRT1_2}
cy={-agent.radius * Math.SQRT1_2}
r={agent.radius * 0.225 * agentMapAttentionMarkerScale(mapScale)}
vectorEffect="none"
aria-hidden="true"
/>
) : null}
{agent.status === 'waiting' ? (
<AgentMapQuestionMarker
radius={agent.radius}
markerScale={agentMapAttentionMarkerScale(mapScale)}
/>
) : null}
</g>
</g>
)
})}
</>
)}
</g>
<WorktreeDetails
project={project}
worktree={worktree}
launchableAgents={launchableAgents}
onSelectAgent={onSelectAgent}
onSpawnAgent={onSpawnAgent}
onDone={() => setDetailsOpen(false)}
/>
</Popover>
)
})
@@ -1,34 +1,10 @@
import { translate } from '@/i18n/i18n'
import type { DashboardCard, DashboardFilterOption } from '../../../../shared/dashboard-snapshot'
import type { DashboardReviewFilter } from './agent-board-filtering'
import type { AgentMapState } from './agent-map-filter'
/** Option rows and labels for the shared dashboard filter menu. */
export type FilterOption = { id: string; label: string; count: number; color?: string }
export const AGENT_STATE_ROWS: {
state: AgentMapState
dotState: 'waiting' | 'working' | 'done' | 'idle'
}[] = [
{ state: 'attention', dotState: 'waiting' },
{ state: 'working', dotState: 'working' },
{ state: 'done', dotState: 'done' },
{ state: 'idle', dotState: 'idle' }
]
export function agentStateLabel(state: AgentMapState): string {
switch (state) {
case 'attention':
return translate('dashboardPopout.bucket.attention', 'Needs You')
case 'working':
return translate('dashboardPopout.bucket.working', 'Working')
case 'done':
return translate('dashboardPopout.bucket.done', 'Done')
case 'idle':
return translate('dashboardPopout.bucket.idle', 'Idle')
}
}
function countBy(
cards: DashboardCard[],
value: (card: DashboardCard) => string
@@ -1,58 +0,0 @@
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import {
agentMapDurationMinutes,
agentMapNodeStatus,
type AgentMapNodeStatus
} from './agent-map-node-metadata'
const GOLDEN_ANGLE = 2.399963229728653
function stableHash(value: string): number {
let hash = 2166136261
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index)
hash = Math.imul(hash, 16777619)
}
return hash >>> 0
}
export function placeAgentMapAgents({
worktreeId,
cards,
radius,
agentRadius,
now
}: {
worktreeId: string
cards: DashboardCard[]
radius: number
agentRadius: number
now: number
}): {
card: DashboardCard
x: number
y: number
radius: number
durationMinutes: number
status: AgentMapNodeStatus
}[] {
const availableRadius = Math.max(0, radius - agentRadius - 6)
const sorted = [...cards].sort((a, b) =>
a.paneKey < b.paneKey ? -1 : a.paneKey > b.paneKey ? 1 : 0
)
const capacity = Math.ceil(Math.sqrt(Math.max(1, sorted.length))) ** 2
const angleOffset = (stableHash(worktreeId) / 0xffffffff) * Math.PI * 2
return sorted.map((card, index) => {
const orbit = sorted.length === 1 ? 0 : Math.sqrt((index + 0.5) / capacity) * availableRadius
const angle = angleOffset + index * GOLDEN_ANGLE
return {
card,
x: Math.cos(angle) * orbit,
y: Math.sin(angle) * orbit,
radius: agentRadius,
durationMinutes: agentMapDurationMinutes(card, now),
status: agentMapNodeStatus(card)
}
})
}
@@ -1,25 +0,0 @@
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
)
}
@@ -1,13 +0,0 @@
import { translate } from '@/i18n/i18n'
import type { AgentMapTimeField } from './agent-map-time-filter'
export function timeFieldLabel(field: AgentMapTimeField): string {
switch (field) {
case 'lifespan':
return translate('dashboardPopout.map.filters.lifespan', 'Session lifespan')
case 'sinceMessage':
return translate('dashboardPopout.map.filters.sinceMessage', 'Since last message')
case 'timeInState':
return translate('dashboardPopout.map.filters.timeInState', 'Time in current state')
}
}
@@ -1,54 +0,0 @@
import { translate } from '@/i18n/i18n'
import {
activeAgentMapTimeFields,
agentMapTimeStopLabel,
type AgentMapTimeRanges
} from './agent-map-time-filter'
export type AgentMapSectionSummary = { text: string; active: boolean }
const all = (): string => translate('dashboardPopout.map.filters.summaryAll', 'All')
/** "All" / the one selected value / "2 of 4" — enough to skip opening the row. */
export function summarizeSelection<T>(
selected: ReadonlySet<T>,
total: number,
label: (value: T) => string
): AgentMapSectionSummary {
if (selected.size >= total) {
return { text: all(), active: false }
}
if (selected.size === 1) {
return { text: label([...selected][0]), active: true }
}
return {
text: translate('dashboardPopout.map.filters.summaryCount', '{{shown}} of {{total}}', {
shown: selected.size,
total
}),
active: true
}
}
export function summarizeTimeRanges(
ranges: AgentMapTimeRanges,
label: (field: keyof AgentMapTimeRanges) => string
): AgentMapSectionSummary {
const active = activeAgentMapTimeFields(ranges)
if (active.length === 0) {
return { text: translate('dashboardPopout.map.filters.timeAny', 'any'), active: false }
}
if (active.length === 1) {
const range = ranges[active[0]]
return {
text: `${label(active[0])}: ${agentMapTimeStopLabel(range.min)}–${agentMapTimeStopLabel(range.max)}`,
active: true
}
}
return {
text: translate('dashboardPopout.map.filters.timeRangeCount', '{{count}} ranges', {
count: active.length
}),
active: true
}
}
@@ -1,113 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { agentMapState, countAgentMapCards, filterAgentMapCards } from './agent-map-filter'
const NOW = 2_000_000_000
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
return {
paneKey: 'pane-1',
ptyId: 'pty-1',
agentType: 'codex',
bucket: 'done',
dotState: 'done',
task: '',
repoId: 'repo-1',
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
repoName: 'Orca',
worktreeName: 'Agent map',
startedAt: NOW - 60_000,
finishedAt: NOW - 30_000,
stateChangedAt: NOW - 30_000,
unseen: false,
hostKind: 'local',
...overrides
}
}
describe('agent map filtering', () => {
it('files both unseen and acknowledged completions under done, never idle', () => {
expect(agentMapState(card({ unseen: true }))).toBe('done')
// Why not idle: an acknowledged finish still paints emerald, so hiding "idle"
// must not blank it out. Only a card that never finished is idle.
expect(agentMapState(card({ unseen: false }))).toBe('done')
expect(agentMapState(card({ bucket: 'idle', dotState: 'idle' }))).toBe('idle')
})
it('applies state and host filters independently', () => {
const hidden = card({ paneKey: 'hidden', repoId: 'hidden', hostKind: 'ssh', unseen: true })
const visible = filterAgentMapCards({
cards: [hidden],
enabledStates: new Set(['done']),
enabledHosts: new Set(['ssh'])
})
expect(visible).toEqual([hidden])
expect(
filterAgentMapCards({
cards: [hidden],
enabledStates: new Set(['idle']),
enabledHosts: new Set(['ssh'])
})
).toEqual([])
expect(
filterAgentMapCards({
cards: [hidden],
enabledStates: new Set(['done']),
enabledHosts: new Set(['local'])
})
).toEqual([])
})
it('keeps every selected host rather than one at a time', () => {
const local = card({ paneKey: 'local' })
const ssh = card({ paneKey: 'ssh', hostKind: 'ssh' })
const wsl = card({ paneKey: 'wsl', hostKind: 'wsl' })
expect(
filterAgentMapCards({
cards: [local, ssh, wsl],
enabledStates: new Set(['done']),
enabledHosts: new Set(['local', 'wsl'])
})
).toEqual([local, wsl])
})
it('treats a missing hostKind as local', () => {
const legacy = card({ paneKey: 'legacy', hostKind: undefined })
expect(
filterAgentMapCards({
cards: [legacy],
enabledStates: new Set(['done']),
enabledHosts: new Set(['local'])
})
).toEqual([legacy])
expect(
filterAgentMapCards({
cards: [legacy],
enabledStates: new Set(['done']),
enabledHosts: new Set(['ssh'])
})
).toEqual([])
})
it('counts all four display states', () => {
const cards = [
card({ paneKey: 'done-new', unseen: true }),
card({ paneKey: 'done-seen', unseen: false }),
card({ paneKey: 'working', bucket: 'working', dotState: 'working', finishedAt: null }),
card({ paneKey: 'waiting', bucket: 'attention', dotState: 'waiting', finishedAt: null }),
card({ paneKey: 'idle', bucket: 'idle', dotState: 'idle', finishedAt: null })
]
expect(countAgentMapCards(cards)).toEqual({
attention: 1,
working: 1,
done: 2,
idle: 1
})
})
})
@@ -1,106 +0,0 @@
import type { DashboardCard, DashboardCardHostKind } from '../../../../shared/dashboard-snapshot'
import { agentMapNodeStatus } from './agent-map-node-metadata'
import { matchesAgentMapTimeRanges, type AgentMapTimeRanges } from './agent-map-time-filter'
export type AgentMapState = 'attention' | 'working' | 'done' | 'idle'
export type AgentMapCounts = Record<AgentMapState, number>
export const ALL_AGENT_MAP_HOSTS: readonly DashboardCardHostKind[] = [
'local',
'ssh',
'wsl',
'remote'
]
export function agentMapState(card: DashboardCard): AgentMapState {
const state = agentMapNodeStatus(card)
if (state === 'blocked' || state === 'waiting') {
return 'attention'
}
// Why: an acknowledged finish still paints emerald, so it has to answer the Done
// chip. Filtering it as idle would let "hide idle" blank out visibly green nodes.
if (state === 'done-seen') {
return 'done'
}
if (state === 'monitoring') {
return 'working'
}
return state
}
/** Every agent in a dispatch relationship — each dispatched child *and* the
* coordinator that dispatched it. A children-only set would hide the half of
* the flow that explains it. */
export function agentMapOrchestrationPaneKeys(cards: DashboardCard[]): Set<string> {
const present = new Set(cards.map((card) => card.paneKey))
const flows = new Set<string>()
for (const card of cards) {
const parent = card.parentPaneKey
if (parent && present.has(parent)) {
flows.add(card.paneKey)
flows.add(parent)
}
}
return flows
}
export function filterAgentMapCards({
cards,
enabledStates,
enabledHosts,
enabledAgentTypes,
timeRanges,
orchestrationOnly = false,
now
}: {
cards: DashboardCard[]
enabledStates: ReadonlySet<AgentMapState>
enabledHosts: ReadonlySet<DashboardCardHostKind>
enabledAgentTypes?: ReadonlySet<string>
timeRanges?: AgentMapTimeRanges
orchestrationOnly?: boolean
now?: number
}): DashboardCard[] {
const flows = orchestrationOnly ? agentMapOrchestrationPaneKeys(cards) : null
// Project filtering lives in the shared toolbar filter, which has already
// narrowed these cards.
return cards.filter((card) => {
if (!enabledHosts.has(card.hostKind ?? 'local')) {
return false
}
if (!enabledStates.has(agentMapState(card))) {
return false
}
if (enabledAgentTypes && !enabledAgentTypes.has(card.agentType)) {
return false
}
if (flows && !flows.has(card.paneKey)) {
return false
}
if (timeRanges && now !== undefined && !matchesAgentMapTimeRanges(card, timeRanges, now)) {
return false
}
return true
})
}
export function countAgentMapCards(cards: DashboardCard[]): AgentMapCounts {
const counts: AgentMapCounts = {
attention: 0,
working: 0,
done: 0,
idle: 0
}
for (const card of cards) {
counts[agentMapState(card)] += 1
}
return counts
}
export function countAgentMapAgentTypes(cards: DashboardCard[]): Map<string, number> {
const counts = new Map<string, number>()
for (const card of cards) {
counts.set(card.agentType, (counts.get(card.agentType) ?? 0) + 1)
}
return new Map([...counts].sort(([a], [b]) => a.localeCompare(b)))
}
@@ -1,94 +0,0 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
function source(file: string): string {
return readFileSync(
resolve(process.cwd(), 'src/renderer/src/components/dashboard-popout', file),
'utf8'
)
}
describe('Agent Map glow performance boundary', () => {
it('uses one conditional SVG halo per active entity without filter effects', () => {
const component = source('AgentMapWorktreeRingNode.tsx')
expect(component.match(/data-agent-map-worktree-status-glow/g)).toHaveLength(1)
expect(component.match(/data-agent-map-agent-status-glow/g)).toHaveLength(1)
expect(component).not.toMatch(/<filter|filter=/)
})
it('keeps glow styling free of animated and filtered paint work', () => {
const css = source('agent-map.css')
const baseGlowRules = css.match(
/\.agent-map-(?:worktree-status|agent-status)-glow\s*\{[^}]+\}/gs
)
const glowRules = css.match(
/\.agent-map-(?:worktree-status|agent-status)-glow[^{}]*\{[^}]+\}/gs
)
expect(baseGlowRules).toHaveLength(2)
for (const rule of baseGlowRules ?? []) {
expect(rule).toContain('pointer-events: none')
expect(rule).toContain('vector-effect: non-scaling-stroke')
}
// 2 base + 4 agent statuses + 4 worktree statuses.
expect(glowRules).toHaveLength(10)
for (const rule of glowRules ?? []) {
expect(rule).not.toMatch(/filter:|animation:|transition:/)
}
const markRule = css.match(/\.agent-map-agent-mark\s*\{[^}]+\}/s)?.[0]
expect(markRule).not.toMatch(/filter:|animation:|transition:/)
})
it('keeps the waiting badge on the native SVG paint path', () => {
const marker = source('AgentMapQuestionMarker.tsx')
const css = source('agent-map.css')
const markerRules = css.match(/\.agent-map-agent-question-[^{}]*\{[^}]+\}/gs) ?? []
expect(marker).not.toContain('<foreignObject')
expect(marker).toContain('<AgentQuestionIcon')
expect(markerRules).toHaveLength(2)
for (const rule of markerRules) {
expect(rule).not.toMatch(/filter:|animation:|transition:/)
}
})
it('confines status flares to nodes inside the recency window', () => {
const component = source('AgentMapWorktreeRingNode.tsx')
const metadata = source('agent-map-node-metadata.ts')
// The flare is the one animated element on an agent node, so it must stay gated on
// the globally capped recent-status map rather than on status alone.
expect(component.match(/data-agent-map-agent-status-flare/g)).toHaveLength(1)
expect(component).toMatch(/recentFlareStatuses\.get\(agent\.card\.paneKey\)/)
expect(component).not.toMatch(/<filter|filter=/)
const css = source('agent-map.css')
const flareRule = css.match(/\.agent-map-agent-status-flare\s*\{[^}]+\}/s)?.[0] ?? ''
expect(flareRule).toContain('pointer-events: none')
expect(flareRule).toContain('vector-effect: non-scaling-stroke')
// Transform and opacity only — no filter or layout-affecting paint work.
expect(flareRule).not.toMatch(/filter:/)
expect(css).toMatch(/@keyframes agent-map-status-flare/)
// The mount window and the CSS duration are declared in different languages and
// drift silently: too short a window unmounts the element mid-ripple, too long
// leaves an invisible node animating. Pin them to the same number.
const windowMs = Number(
metadata.match(/AGENT_MAP_STATUS_FLARE_MS = ([\d_]+)/)?.[1].replaceAll('_', '')
)
const cssMs = Number(flareRule.match(/animation: agent-map-status-flare (\d+)ms/)?.[1])
expect(windowMs).toBeGreaterThan(0)
expect(cssMs).toBe(windowMs)
})
it('selects capped flares before rendering instead of flattening the scene', () => {
const map = source('AgentMap.tsx')
const scene = source('AgentMapScene.tsx')
expect(map).toMatch(/selectAgentMapRecentFlareStatuses\(visibleCards\)/)
expect(scene).not.toContain('selectAgentMapRecentFlareStatuses')
})
})
@@ -1,49 +0,0 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const css = readFileSync(
resolve(process.cwd(), 'src/renderer/src/components/dashboard-popout/agent-map.css'),
'utf8'
)
const PROJECT_SCALE =
/:where\(\s*\.agent-map-project-node:hover,\s*\.agent-map-project-node:focus-within,\s*\.agent-map-project-node\.is-held\s*\)\s*\.agent-map-project-ring\s*\{([^}]*)\}/
const WORKTREE_SCALE =
/:where\(\s*\.agent-map-worktree-group:hover,\s*\.agent-map-worktree-group:focus-within,\s*\.agent-map-worktree-group\.is-held\s*\)\s*\.agent-map-worktree-ring\s*\{([^}]*)\}/
/** A ring that only reacts to :hover on itself pulses shut whenever the pointer
* crosses onto something drawn inside it, and again when a pan drag takes
* pointer capture. Both triggers have to live on the containing group. */
describe('Agent Map hover containment', () => {
it('scales each ring from its containing group, never from the ring element', () => {
expect(css).not.toMatch(/\.agent-map-(?:project|worktree)-ring:hover/)
expect(css.match(PROJECT_SCALE)?.[1]).toContain('transform: scale')
expect(css.match(WORKTREE_SCALE)?.[1]).toContain('transform: scale')
})
it('keeps the group-scoped hover at ring specificity so state rules still win', () => {
// `:where()` contributes no specificity, so the workspace state rules keep
// overriding hover fill and stroke — but only while they stay below it.
const hoverAt = css.search(WORKTREE_SCALE)
expect(hoverAt).toBeGreaterThan(css.indexOf('.agent-map-worktree-ring {'))
for (const state of ['.is-open', '.is-selected', '.is-working', '.is-blocked']) {
expect(css.indexOf(`.agent-map-worktree-ring${state}`)).toBeGreaterThan(hoverAt)
}
})
it('expands containing rings for keyboard focus as well as pointer hover', () => {
expect(css.match(PROJECT_SCALE)?.[0]).toContain('.agent-map-project-node:focus-within')
expect(css.match(WORKTREE_SCALE)?.[0]).toContain('.agent-map-worktree-group:focus-within')
})
it('drops both hover triggers under reduced motion', () => {
const reducedMotion = css.slice(css.indexOf('@media (prefers-reduced-motion: reduce)'))
expect(reducedMotion).toContain('.agent-map-project-node.is-held')
expect(reducedMotion).toContain('.agent-map-worktree-group.is-held')
expect(reducedMotion).toMatch(/\.agent-map-project-node:hover/)
expect(reducedMotion).toMatch(/\.agent-map-worktree-group:hover/)
})
})
@@ -1,151 +0,0 @@
import { describe, expect, it } from 'vitest'
import { selectVisibleAgentMapLabels } from './agent-map-label-declutter'
import type { AgentMapLayout, AgentMapProjectRing, AgentMapWorktreeRing } from './agent-map-layout'
import {
agentMapQuietCount,
emptyAgentMapStatusCounts,
type AgentMapStatusCounts
} from './agent-map-node-metadata'
function statusCounts(overrides: Partial<AgentMapStatusCounts> = {}): AgentMapStatusCounts {
return { ...emptyAgentMapStatusCounts(), ...overrides }
}
function worktree(overrides: Partial<AgentMapWorktreeRing> = {}): AgentMapWorktreeRing {
const counts = overrides.statusCounts ?? statusCounts({ working: 1 })
const total = Object.values(counts).reduce((sum, value) => sum + value, 0)
return {
id: 'worktree-a',
worktreeId: 'worktree-a',
executionHostId: undefined,
name: 'alpha',
workspaceKind: 'worktree',
x: 0,
y: 0,
radius: 62,
// Sparse placeholders keep tests that only care about label-to-label collisions concise.
agents: Array.from({ length: total }) as AgentMapWorktreeRing['agents'],
statusCounts: counts,
quiet: agentMapQuietCount(counts) === total,
...overrides
}
}
function layoutOf(
worktrees: AgentMapWorktreeRing[],
project: Partial<AgentMapProjectRing> = {}
): AgentMapLayout {
return {
projects: [
{
id: 'project-1',
name: 'orca',
x: 0,
// Parked far above the workspaces so the project label is not itself
// the thing under test unless a case moves it.
y: -4_000,
radius: 100,
worktrees,
agentCount: worktrees.reduce((sum, item) => sum + item.agents.length, 0),
...project
}
],
width: 900,
height: 560,
topologyKey: 'test'
}
}
describe('selectVisibleAgentMapLabels', () => {
it('keeps both labels when they are far enough apart', () => {
const layout = layoutOf([
worktree({ id: 'a', x: -400, y: 0 }),
worktree({ id: 'b', name: 'beta', x: 400, y: 0 })
])
const { worktreeIds } = selectVisibleAgentMapLabels(layout, 1, 1)
expect([...worktreeIds].sort()).toEqual(['a', 'b'])
})
it('drops the lower-priority label when two would overlap', () => {
// Same anchor point: the two labels are drawn on top of each other.
const layout = layoutOf([
worktree({ id: 'busy', x: 0, y: 0, statusCounts: statusCounts({ working: 3 }) }),
worktree({ id: 'calm', name: 'beta', x: 0, y: 0, statusCounts: statusCounts({ done: 1 }) })
])
const { worktreeIds } = selectVisibleAgentMapLabels(layout, 1, 1)
expect([...worktreeIds]).toEqual(['busy'])
})
it('hides a workspace title that would cover an agent', () => {
const coveringAgent = { x: 0, y: -48, radius: 20 }
const covered = worktree({
id: 'covered',
agents: [coveringAgent] as AgentMapWorktreeRing['agents']
})
const labels = selectVisibleAgentMapLabels(layoutOf([covered]), 1, 1)
expect(labels.worktreeIds.size).toBe(0)
})
it('lets a blocked workspace outrank a busier neighbour for the surviving label', () => {
const layout = layoutOf([
worktree({ id: 'blocked', x: 0, y: 0, statusCounts: statusCounts({ blocked: 1 }) }),
worktree({
id: 'working',
name: 'beta',
x: 0,
y: 0,
statusCounts: statusCounts({ working: 9 })
})
])
const { worktreeIds } = selectVisibleAgentMapLabels(layout, 1, 1)
expect([...worktreeIds]).toEqual(['blocked'])
})
it('hides all-idle workspace labels until the ring is large on screen', () => {
const idle = worktree({ id: 'idle', x: 0, y: 0, statusCounts: statusCounts({ idle: 2 }) })
expect(selectVisibleAgentMapLabels(layoutOf([idle]), 1, 0.5).worktreeIds.size).toBe(0)
expect([...selectVisibleAgentMapLabels(layoutOf([idle]), 1, 1).worktreeIds]).toEqual(['idle'])
})
it('drops the project count rather than a workspace name when they collide', () => {
// The workspace ring's name lands on the project's count line.
const layout = layoutOf([worktree({ id: 'a', x: 0, y: 42 })], {
x: 0,
y: 0,
radius: 40,
agentCount: 1
})
const { worktreeIds, projectCountIds } = selectVisibleAgentMapLabels(layout, 1, 1)
expect([...worktreeIds]).toEqual(['a'])
expect(projectCountIds.size).toBe(0)
})
it('keeps the project count when nothing is in its way', () => {
const layout = layoutOf([worktree({ id: 'a', x: 0, y: 0 })])
expect([...selectVisibleAgentMapLabels(layout, 1, 1).projectCountIds]).toEqual(['project-1'])
})
it('admits more labels as zooming in shrinks their world footprint', () => {
const worktrees = Array.from({ length: 6 }, (_unused, index) =>
worktree({ id: `w-${index}`, name: `workspace-${index}`, x: index * 90, y: 0 })
)
const zoomedOut = selectVisibleAgentMapLabels(layoutOf(worktrees), 4, 0.25).worktreeIds
const zoomedIn = selectVisibleAgentMapLabels(layoutOf(worktrees), 1, 1).worktreeIds
expect(zoomedOut.size).toBeLessThan(zoomedIn.size)
expect(zoomedIn.size).toBe(6)
})
})
@@ -1,266 +0,0 @@
import type { AgentMapLayout, AgentMapWorktreeRing } from './agent-map-layout'
/** Metrics for the label styles in agent-map.css. Estimating text extents from
* the character count avoids a per-frame DOM measure of every label. */
const WORKTREE_LABEL_FONT_PX = 12
const PROJECT_LABEL_FONT_PX = 13
const COUNT_FONT_PX = 11
const GLYPH_WIDTH_RATIO = 0.56
/** Uppercase project text runs wider per glyph than a mixed-case worktree name. */
const UPPERCASE_GLYPH_WIDTH_RATIO = 0.66
const ASCENT_RATIO = 0.8
const DESCENT_RATIO = 0.2
const PROJECT_LABEL_ICON_PX = 16
/** Local-unit breathing room so two labels never appear to touch. */
const LABEL_GAP_X_PX = 3
const LABEL_GAP_Y_PX = 1
const AGENT_LABEL_CLEARANCE_PX = 3
/** Baselines the scene renders at, relative to each label group's origin. */
const WORKTREE_LABEL_BASELINE = 18
const COUNT_BASELINE = 32
const PROJECT_NAME_TOP = 3
const PROJECT_NAME_BOTTOM = 21
/** Past this many candidates the pass stops admitting labels; a map that dense
* is unreadable long before the cap, and this bounds the work. */
const MAX_LABEL_CANDIDATES = 600
const DECLUTTER_GRID_PX = 96
/** A label box in world units, matching what the scene actually renders. */
type LabelBox = {
left: number
right: number
top: number
bottom: number
}
type LabelGrid = Map<number, Map<number, LabelBox[]>>
export type AgentMapVisibleLabels = {
/** Worktree ring ids whose name can render without colliding. */
worktreeIds: Set<string>
/** Project ids whose agent/workspace count line still has room. The count is
* the first thing dropped: it repeats what the filter rail already says. */
projectCountIds: Set<string>
}
function textWidth(text: string, fontPx: number, ratio = GLYPH_WIDTH_RATIO): number {
return text.length * fontPx * ratio
}
function boxesOverlap(a: LabelBox, b: LabelBox): boolean {
return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom
}
function addBox(grid: LabelGrid, box: LabelBox): void {
const left = Math.floor(box.left / DECLUTTER_GRID_PX)
const right = Math.floor(box.right / DECLUTTER_GRID_PX)
const top = Math.floor(box.top / DECLUTTER_GRID_PX)
const bottom = Math.floor(box.bottom / DECLUTTER_GRID_PX)
for (let x = left; x <= right; x += 1) {
let column = grid.get(x)
if (!column) {
column = new Map()
grid.set(x, column)
}
for (let y = top; y <= bottom; y += 1) {
const cell = column.get(y)
if (cell) {
cell.push(box)
} else {
column.set(y, [box])
}
}
}
}
function collides(grid: LabelGrid, box: LabelBox): boolean {
const left = Math.floor(box.left / DECLUTTER_GRID_PX)
const right = Math.floor(box.right / DECLUTTER_GRID_PX)
const top = Math.floor(box.top / DECLUTTER_GRID_PX)
const bottom = Math.floor(box.bottom / DECLUTTER_GRID_PX)
for (let x = left; x <= right; x += 1) {
const column = grid.get(x)
if (!column) {
continue
}
for (let y = top; y <= bottom; y += 1) {
for (const placed of column.get(y) ?? []) {
if (boxesOverlap(box, placed)) {
return true
}
}
}
}
return false
}
/** Projects a centered label from its group's local units into world space
* through the same scale the scene applies to the group. */
function centeredBox(
centerX: number,
anchorY: number,
scale: number,
width: number,
localTop: number,
localBottom: number
): LabelBox {
const halfWidth = (width / 2 + LABEL_GAP_X_PX) * scale
return {
left: centerX - halfWidth,
right: centerX + halfWidth,
top: anchorY + (localTop - LABEL_GAP_Y_PX) * scale,
bottom: anchorY + (localBottom + LABEL_GAP_Y_PX) * scale
}
}
/** Box for a centered <text> given its baseline in the group's local units. */
function baselineBox(
centerX: number,
anchorY: number,
scale: number,
text: string,
fontPx: number,
baseline: number,
widthRatio = GLYPH_WIDTH_RATIO
): LabelBox {
return centeredBox(
centerX,
anchorY,
scale,
textWidth(text, fontPx, widthRatio),
baseline - fontPx * ASCENT_RATIO,
baseline + fontPx * DESCENT_RATIO
)
}
/** Attention outranks volume: a blocked workspace keeps its name when a large
* idle neighbour has to drop its own. */
function labelPriority(worktree: AgentMapWorktreeRing): number {
const attention = worktree.statusCounts.blocked + worktree.statusCounts.waiting
return (
attention * 1_000_000 +
worktree.statusCounts.working * 10_000 +
worktree.statusCounts.monitoring * 1_000 +
worktree.agents.length
)
}
/** Worth drawing before collisions are considered: all-idle workspaces stay
* silent until their ring is big enough on screen to be worth naming. */
function isLabelCandidate(worktree: AgentMapWorktreeRing, mapScale: number): boolean {
return !worktree.quiet || worktree.radius * mapScale >= 56
}
function compareStable(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0
}
function addAgentExclusionBoxes(grid: LabelGrid, layout: AgentMapLayout, mapScale: number): void {
const clearance = AGENT_LABEL_CLEARANCE_PX / Math.max(mapScale, 0.001)
for (const project of layout.projects) {
for (const worktree of project.worktrees) {
for (const agent of worktree.agents) {
if (!agent) {
continue
}
const radius = agent.radius + clearance
addBox(grid, {
left: agent.x - radius,
right: agent.x + radius,
top: agent.y - radius,
bottom: agent.y + radius
})
}
}
}
}
/**
* Picks the labels that can render without stacking on each other. Labels draw
* at a fixed screen size, so which ones fit depends on zoom but never on pan —
* keeping this pan-independent is what lets the scene stay memoized during a
* drag. Project names claim space first as the map's coarsest landmark,
* workspace names next in priority order, and project counts take what is left.
*/
export function selectVisibleAgentMapLabels(
layout: AgentMapLayout,
labelScale: number,
mapScale: number
): AgentMapVisibleLabels {
const agentGrid: LabelGrid = new Map()
addAgentExclusionBoxes(agentGrid, layout, mapScale)
const grid: LabelGrid = new Map()
addAgentExclusionBoxes(grid, layout, mapScale)
for (const project of layout.projects) {
const name = project.name.toUpperCase()
addBox(
grid,
centeredBox(
project.x,
project.y - project.radius,
labelScale,
PROJECT_LABEL_ICON_PX + textWidth(name, PROJECT_LABEL_FONT_PX, UPPERCASE_GLYPH_WIDTH_RATIO),
PROJECT_NAME_TOP,
PROJECT_NAME_BOTTOM
)
)
}
const candidates: AgentMapWorktreeRing[] = []
for (const project of layout.projects) {
for (const worktree of project.worktrees) {
if (isLabelCandidate(worktree, mapScale)) {
candidates.push(worktree)
}
}
}
candidates.sort((a, b) => {
const byPriority = labelPriority(b) - labelPriority(a)
if (byPriority !== 0) {
return byPriority
}
const byRadius = b.radius - a.radius
return byRadius !== 0 ? byRadius : compareStable(a.id, b.id)
})
const worktreeIds = new Set<string>()
for (const worktree of candidates.slice(0, MAX_LABEL_CANDIDATES)) {
const box = baselineBox(
worktree.x,
worktree.y - worktree.radius,
labelScale,
worktree.name,
WORKTREE_LABEL_FONT_PX,
WORKTREE_LABEL_BASELINE
)
if (collides(agentGrid, box)) {
continue
}
if (collides(grid, box)) {
continue
}
addBox(grid, box)
worktreeIds.add(worktree.id)
}
const projectCountIds = new Set<string>()
for (const project of layout.projects) {
const count = `${project.agentCount} AGENTS · ${project.worktrees.length} WORKSPACES`
const box = baselineBox(
project.x,
project.y - project.radius,
labelScale,
count,
COUNT_FONT_PX,
COUNT_BASELINE,
UPPERCASE_GLYPH_WIDTH_RATIO
)
if (collides(grid, box)) {
continue
}
addBox(grid, box)
projectCountIds.add(project.id)
}
return { worktreeIds, projectCountIds }
}
@@ -1,69 +0,0 @@
import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot'
import type { AgentMapLayout } from './agent-map-layout'
import { agentMapWorkspaceIdentity } from './agent-map-workspace-identity'
import {
agentMapDurationMinutes,
agentMapNodeStatus,
agentMapQuietCount,
emptyAgentMapStatusCounts
} from './agent-map-node-metadata'
export function refreshAgentMapMetadata(
geometry: AgentMapLayout,
cards: DashboardCard[],
workspaces: DashboardWorkspace[],
now: number
): AgentMapLayout {
const cardsByPaneKey = new Map(cards.map((card) => [card.paneKey, card]))
const workspacesById = new Map(
workspaces.map((workspace) => [agentMapWorkspaceIdentity(workspace), workspace])
)
const projects = geometry.projects.map((project) => {
let projectName = project.name
let agentCount = 0
const worktrees = project.worktrees.map((worktree) => {
const workspace = workspacesById.get(worktree.id)
if (workspace) {
projectName = workspace.repoName
}
let worktreeName = workspace?.worktreeName ?? worktree.name
let workspaceKind = workspace?.workspaceKind ?? worktree.workspaceKind
let hostKind = workspace?.hostKind ?? worktree.hostKind
let hostLabel = workspace?.hostLabel ?? worktree.hostLabel
const statusCounts = emptyAgentMapStatusCounts()
const agents = worktree.agents.flatMap((agent) => {
const card = cardsByPaneKey.get(agent.card.paneKey)
if (!card) {
return []
}
projectName = card.repoName
worktreeName = card.worktreeName
workspaceKind = card.workspaceKind ?? 'worktree'
hostKind = card.hostKind ?? hostKind
hostLabel = card.hostLabel ?? hostLabel
agentCount += 1
statusCounts[agentMapNodeStatus(card)] += 1
return [
{
...agent,
card,
durationMinutes: agentMapDurationMinutes(card, now),
status: agentMapNodeStatus(card)
}
]
})
return {
...worktree,
name: worktreeName,
workspaceKind,
hostKind,
hostLabel,
agents,
statusCounts,
quiet: agentMapQuietCount(statusCounts) === agents.length
}
})
return { ...project, name: projectName, worktrees, agentCount }
})
return { ...geometry, projects }
}
@@ -1,658 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot'
import {
deriveAgentMapLayout,
AGENT_MAP_AGENT_RADIUS,
AGENT_MAP_RING_HEADER_HEIGHT,
AGENT_MAP_WORKTREE_GAP,
agentMapDurationMinutes,
agentMapNodeStatus,
updateAgentMapLayout
} from './agent-map-layout'
import type * as WorktreePackingModule from './agent-map-worktree-packing'
const packWorktrees = vi.hoisted(() => vi.fn())
vi.mock('./agent-map-worktree-packing', async (importOriginal) => {
const actual = await importOriginal<typeof WorktreePackingModule>()
return {
...actual,
packAgentMapWorktrees: (...args: Parameters<typeof actual.packAgentMapWorktrees>) => {
packWorktrees()
return actual.packAgentMapWorktrees(...args)
}
}
})
const NOW = 2_000_000_000
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
return {
paneKey: 'pane-1',
ptyId: 'pty-1',
agentType: 'codex',
bucket: 'working',
dotState: 'working',
task: 'Build map',
repoId: 'repo-1',
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
repoName: 'Orca',
worktreeName: 'Agent map',
startedAt: NOW - 10 * 60_000,
finishedAt: null,
stateChangedAt: NOW - 1_000,
unseen: false,
...overrides
}
}
function workspace(overrides: Partial<DashboardWorkspace> = {}): DashboardWorkspace {
return {
repoId: 'repo-1',
worktreeId: 'empty-worktree',
repoName: 'Orca',
worktreeName: 'Empty worktree',
hostKind: 'local',
executionHostId: 'local',
workspaceKind: 'worktree',
...overrides
}
}
describe('agent map layout', () => {
it('lays out agentless workspaces and preserves their workspace lineage', () => {
const layout = deriveAgentMapLayout([], NOW, [
workspace({ worktreeId: 'parent', worktreeName: 'Parent' }),
workspace({
worktreeId: 'child',
worktreeName: 'Child',
parentWorktreeId: 'parent'
})
])
const project = layout.projects[0]
const parent = project.worktrees.find((item) => item.worktreeId === 'parent')!
const child = project.worktrees.find((item) => item.worktreeId === 'child')!
expect(project.agentCount).toBe(0)
expect(parent.agents).toEqual([])
expect(child.parentId).toBe(parent.id)
expect(child.y).toBeGreaterThan(parent.y)
})
it('derives project containment, workspace containment, and every agent node', () => {
const cards = [
card({ paneKey: 'a', repoId: 'repo-a', worktreeId: 'wt-a' }),
card({ paneKey: 'b', repoId: 'repo-a', worktreeId: 'wt-a' }),
card({ paneKey: 'c', repoId: 'repo-a', worktreeId: 'wt-b' }),
card({
paneKey: 'd',
repoId: 'repo-b',
repoName: 'Mobile',
worktreeId: 'wt-c'
})
]
const layout = deriveAgentMapLayout(cards, NOW)
expect(layout.projects.map((project) => project.id)).toEqual(['repo-a', 'repo-b'])
expect(layout.projects[0].worktrees.map((worktree) => worktree.worktreeId)).toEqual([
'wt-a',
'wt-b'
])
expect(
layout.projects.flatMap((project) =>
project.worktrees.flatMap((worktree) => worktree.agents.map((agent) => agent.card.paneKey))
)
).toEqual(['a', 'b', 'c', 'd'])
for (const project of layout.projects) {
for (const worktree of project.worktrees) {
expect(
Math.hypot(worktree.x - project.x, worktree.y - project.y) + worktree.radius
).toBeLessThan(project.radius)
for (const agent of worktree.agents) {
expect(
Math.hypot(agent.x - worktree.x, agent.y - worktree.y) + agent.radius
).toBeLessThan(worktree.radius)
}
}
}
})
it('keeps exact worktree IDs on different hosts in separate rings', () => {
const layout = deriveAgentMapLayout(
[
card({ paneKey: 'local', executionHostId: 'local' }),
card({ paneKey: 'remote', executionHostId: 'runtime:env-1' })
],
NOW
)
expect(layout.projects[0].worktrees).toHaveLength(2)
expect(
layout.projects[0].worktrees.map(({ worktreeId, executionHostId }) => ({
worktreeId,
executionHostId
}))
).toEqual(
expect.arrayContaining([
{ worktreeId: 'worktree-1', executionHostId: 'local' },
{ worktreeId: 'worktree-1', executionHostId: 'runtime:env-1' }
])
)
})
it('preserves remote host presentation on its workspace ring', () => {
const layout = deriveAgentMapLayout(
[
card({
executionHostId: 'ssh:opaque-target',
hostKind: 'ssh',
hostLabel: 'openclaw'
})
],
NOW
)
expect(layout.projects[0].worktrees[0]).toMatchObject({
executionHostId: 'ssh:opaque-target',
hostKind: 'ssh',
hostLabel: 'openclaw'
})
})
it('reserves project and workspace header bands above dense ring contents', () => {
const layout = deriveAgentMapLayout(
Array.from({ length: 24 }, (_unused, index) =>
card({ paneKey: `dense-${index.toString().padStart(2, '0')}` })
),
NOW
)
const project = layout.projects[0]
const worktree = project.worktrees[0]
const projectTop = project.y - project.radius
const worktreeTop = worktree.y - worktree.radius
const agentTop = Math.min(...worktree.agents.map((agent) => agent.y - agent.radius))
expect(worktreeTop - projectTop).toBeGreaterThanOrEqual(AGENT_MAP_RING_HEADER_HEIGHT)
expect(agentTop - worktreeTop).toBeGreaterThanOrEqual(AGENT_MAP_RING_HEADER_HEIGHT)
})
it('keeps sparse project and workspace rings compact around their header bands', () => {
const single = deriveAgentMapLayout([card()], NOW).projects[0]
const four = deriveAgentMapLayout(
Array.from({ length: 4 }, (_unused, index) => card({ paneKey: `agent-${index}` })),
NOW
).projects[0]
expect(single.worktrees[0].radius).toBeLessThanOrEqual(72)
expect(single.radius).toBeLessThanOrEqual(104)
expect(four.worktrees[0].radius).toBeLessThanOrEqual(100)
})
it.each([
['single', [card()]],
[
'sparse',
[
card({ paneKey: 'a', repoId: 'repo-a' }),
card({ paneKey: 'b', repoId: 'repo-b', worktreeId: 'worktree-2' })
]
]
])('centers %s project content within the minimum world', (_, cards) => {
const layout = deriveAgentMapLayout(cards, NOW)
const left = Math.min(...layout.projects.map((project) => project.x - project.radius))
const right = Math.max(...layout.projects.map((project) => project.x + project.radius))
const top = Math.min(...layout.projects.map((project) => project.y - project.radius))
const bottom = Math.max(...layout.projects.map((project) => project.y + project.radius))
expect(layout.width).toBe(900)
expect(layout.height).toBe(560)
expect((left + right) / 2).toBeCloseTo(layout.width / 2)
expect((top + bottom) / 2).toBeCloseTo(layout.height / 2)
})
it('places spawned descendants beneath their direct parent inside the workspace', () => {
const layout = deriveAgentMapLayout(
[
card({ paneKey: 'parent' }),
card({ paneKey: 'child-a', parentPaneKey: 'parent' }),
card({ paneKey: 'child-b', parentPaneKey: 'parent' }),
card({ paneKey: 'grandchild', parentPaneKey: 'child-a' })
],
NOW
)
const worktree = layout.projects[0].worktrees[0]
const agents = new Map(worktree.agents.map((agent) => [agent.card.paneKey, agent]))
const parent = agents.get('parent')!
const childA = agents.get('child-a')!
const childB = agents.get('child-b')!
const grandchild = agents.get('grandchild')!
expect(childA.y).toBeGreaterThan(parent.y)
expect(childB.y).toBeGreaterThan(parent.y)
expect(grandchild.y).toBeGreaterThan(childA.y)
for (const agent of worktree.agents) {
expect(Math.hypot(agent.x - worktree.x, agent.y - worktree.y) + agent.radius).toBeLessThan(
worktree.radius
)
}
})
it('packs high-fanout spawned children into a compact deterministic cluster', () => {
const cards = [
card({ paneKey: 'parent' }),
...Array.from({ length: 29 }, (_, index) =>
card({
paneKey: `child-${index.toString().padStart(2, '0')}`,
parentPaneKey: 'parent'
})
)
]
const first = deriveAgentMapLayout(cards, NOW).projects[0].worktrees[0]
const second = deriveAgentMapLayout(cards, NOW).projects[0].worktrees[0]
const parent = first.agents.find((agent) => agent.card.paneKey === 'parent')!
const children = first.agents.filter((agent) => agent.card.parentPaneKey === 'parent')
expect(new Set(children.map((child) => child.y.toFixed(3))).size).toBeGreaterThan(4)
expect(children.every((child) => child.y > parent.y)).toBe(true)
expect(
Math.max(...children.map((child) => child.x)) - Math.min(...children.map((child) => child.x))
).toBeLessThan(500)
expect(first.radius).toBeLessThan(350)
for (const [index, child] of children.entries()) {
for (const other of children.slice(index + 1)) {
expect(Math.hypot(child.x - other.x, child.y - other.y)).toBeGreaterThanOrEqual(
AGENT_MAP_AGENT_RADIUS * 2
)
}
}
expect(first.agents.map(({ card, x, y }) => ({ paneKey: card.paneKey, x, y }))).toEqual(
second.agents.map(({ card, x, y }) => ({ paneKey: card.paneKey, x, y }))
)
})
it('places visible child worktrees beneath their direct parent', () => {
const layout = deriveAgentMapLayout(
[
card({ paneKey: 'parent', worktreeId: 'parent-worktree' }),
card({
paneKey: 'child-a',
worktreeId: 'child-a-worktree',
parentWorktreeId: 'parent-worktree'
}),
card({
paneKey: 'child-b',
worktreeId: 'child-b-worktree',
parentWorktreeId: 'parent-worktree'
}),
card({
paneKey: 'grandchild',
worktreeId: 'grandchild-worktree',
parentWorktreeId: 'child-a-worktree'
})
],
NOW
)
const worktrees = new Map(
layout.projects[0].worktrees.map((worktree) => [worktree.worktreeId, worktree])
)
const parent = worktrees.get('parent-worktree')!
const childA = worktrees.get('child-a-worktree')!
const childB = worktrees.get('child-b-worktree')!
const grandchild = worktrees.get('grandchild-worktree')!
expect(childA.y).toBeGreaterThan(parent.y)
expect(childB.y).toBeGreaterThan(parent.y)
expect(grandchild.y).toBeGreaterThan(childA.y)
for (const [index, worktree] of layout.projects[0].worktrees.entries()) {
for (const other of layout.projects[0].worktrees.slice(index + 1)) {
expect(Math.hypot(worktree.x - other.x, worktree.y - other.y)).toBeGreaterThanOrEqual(
worktree.radius + other.radius + AGENT_MAP_WORKTREE_GAP - 0.001
)
}
}
})
it('clusters cross-worktree spawned agents without inventing workspace lineage', () => {
const layout = deriveAgentMapLayout(
[
card({ paneKey: 'parent', worktreeId: 'parent-worktree' }),
card({
paneKey: 'child',
worktreeId: 'child-worktree',
parentPaneKey: 'parent'
}),
card({ paneKey: 'unrelated', worktreeId: 'unrelated-worktree' })
],
NOW
)
const worktrees = new Map(
layout.projects[0].worktrees.map((worktree) => [worktree.worktreeId, worktree])
)
const parent = worktrees.get('parent-worktree')!
const child = worktrees.get('child-worktree')!
expect(child.clusterParentId).toBe(parent.id)
expect(child.parentId).toBeUndefined()
expect(child.y).toBeGreaterThan(parent.y)
expect(Math.hypot(child.x - parent.x, child.y - parent.y)).toBeLessThan(
child.radius + parent.radius + 100
)
})
it('clusters spawned agents whose parent is in another project', () => {
const layout = deriveAgentMapLayout(
[
card({ paneKey: 'parent', repoId: 'repo-parent', worktreeId: 'parent-worktree' }),
card({
paneKey: 'child',
repoId: 'repo-child',
worktreeId: 'child-worktree',
parentPaneKey: 'parent'
}),
card({ paneKey: 'unrelated', repoId: 'repo-unrelated', worktreeId: 'unrelated-worktree' })
],
NOW
)
const projects = new Map(layout.projects.map((project) => [project.id, project]))
const parent = projects.get('repo-parent')!
const child = projects.get('repo-child')!
expect(child.y).toBeGreaterThan(parent.y)
expect(Math.hypot(child.x - parent.x, child.y - parent.y)).toBeLessThan(
child.radius + parent.radius + 100
)
})
it('repacks cached geometry when a worktree parent changes', () => {
const cards = [
card({ paneKey: 'parent-a', worktreeId: 'parent-a' }),
card({ paneKey: 'parent-b', worktreeId: 'parent-b' }),
card({ paneKey: 'child', worktreeId: 'child', parentWorktreeId: 'parent-a' })
]
const initial = updateAgentMapLayout(null, cards, NOW)
const updated = updateAgentMapLayout(
initial.cache,
cards.map((candidate) =>
candidate.worktreeId === 'child'
? { ...candidate, parentWorktreeId: 'parent-b' }
: candidate
),
NOW
)
expect(updated.cache).not.toBe(initial.cache)
expect(updated.cache.packingGeneration).toBe(2)
})
it('repacks cached geometry when a spawn parent changes', () => {
const cards = [
card({ paneKey: 'parent-a' }),
card({ paneKey: 'parent-b' }),
card({ paneKey: 'child', parentPaneKey: 'parent-a' })
]
const initial = updateAgentMapLayout(null, cards, NOW)
const updated = updateAgentMapLayout(
initial.cache,
cards.map((candidate) =>
candidate.paneKey === 'child' ? { ...candidate, parentPaneKey: 'parent-b' } : candidate
),
NOW
)
expect(updated.cache).not.toBe(initial.cache)
expect(updated.cache.packingGeneration).toBe(2)
expect(updated.layout.topologyKey).not.toBe(initial.layout.topologyKey)
})
it('keeps positions stable across routine status and duration updates', () => {
const initialCards = [
card({ paneKey: 'a', worktreeId: 'wt-a' }),
card({ paneKey: 'b', worktreeId: 'wt-a', startedAt: NOW - 2 * 60_000 }),
card({ paneKey: 'c', worktreeId: 'wt-b' })
]
const initial = deriveAgentMapLayout(initialCards, NOW)
const updated = deriveAgentMapLayout(
[
{ ...initialCards[0], bucket: 'attention', dotState: 'waiting' },
{ ...initialCards[1], startedAt: NOW - 45 * 60_000 },
initialCards[2]
],
NOW
)
const initialAgents = initial.projects[0].worktrees[0].agents
const updatedAgents = updated.projects[0].worktrees[0].agents
const initialWorktrees = initial.projects[0].worktrees
const updatedWorktrees = updated.projects[0].worktrees
expect(updated.topologyKey).toBe(initial.topologyKey)
expect(updatedWorktrees.map(({ x, y }) => ({ x, y }))).toEqual(
initialWorktrees.map(({ x, y }) => ({ x, y }))
)
expect(updatedAgents.map(({ x, y }) => ({ x, y }))).toEqual(
initialAgents.map(({ x, y }) => ({ x, y }))
)
expect(updatedAgents[1].radius).toBe(initialAgents[1].radius)
})
it('reuses packed geometry while refreshing live card metadata', () => {
const initialCards = [
card({ paneKey: 'a', worktreeId: 'wt-a' }),
card({ paneKey: 'b', worktreeId: 'wt-b' })
]
const initial = updateAgentMapLayout(null, initialCards, NOW)
packWorktrees.mockClear()
const updatedCards = [
{ ...initialCards[0], dotState: 'waiting' as const, worktreeName: 'Renamed' },
{ ...initialCards[1], startedAt: NOW - 60 * 60_000 }
]
const updated = updateAgentMapLayout(initial.cache, updatedCards, NOW + 60_000)
expect(updated.cache).toBe(initial.cache)
expect(packWorktrees).not.toHaveBeenCalled()
expect(initial.cache.geometry).toBe(initial.layout)
expect(updated.cache.packingGeneration).toBe(1)
expect(updated.layout.projects[0].worktrees[0].name).toBe('Renamed')
expect(updated.layout.projects[0].worktrees[0].statusCounts.waiting).toBe(1)
expect(updated.layout.projects[0].worktrees[1].agents[0].durationMinutes).toBe(61)
const topologyChanged = updateAgentMapLayout(
updated.cache,
[...updatedCards, card({ paneKey: 'c', worktreeId: 'wt-c' })],
NOW
)
expect(topologyChanged.cache).not.toBe(updated.cache)
expect(packWorktrees).toHaveBeenCalled()
expect(topologyChanged.cache.packingGeneration).toBe(2)
})
it('refreshes saved host labels without repacking geometry', () => {
const cards = [
card({
executionHostId: 'ssh:builder',
hostKind: 'ssh',
hostLabel: 'Builder'
})
]
const workspaces = [
workspace({
worktreeId: 'worktree-1',
executionHostId: 'ssh:builder',
hostKind: 'ssh',
hostLabel: 'Builder'
})
]
const initial = updateAgentMapLayout(null, cards, NOW, workspaces)
packWorktrees.mockClear()
const updated = updateAgentMapLayout(
initial.cache,
cards.map((candidate) => ({ ...candidate, hostLabel: 'CI Builder' })),
NOW,
workspaces.map((candidate) => ({ ...candidate, hostLabel: 'CI Builder' }))
)
expect(updated.cache).toBe(initial.cache)
expect(updated.cache.packingGeneration).toBe(1)
expect(packWorktrees).not.toHaveBeenCalled()
expect(updated.layout.projects[0].worktrees[0].hostLabel).toBe('CI Builder')
})
it('packs worktree rings tightly without a square grid', () => {
const layout = deriveAgentMapLayout(
Array.from({ length: 36 }, (_, index) =>
card({
paneKey: `agent-${index}`,
worktreeId: `worktree-${index.toString().padStart(2, '0')}`
})
),
NOW
)
const project = layout.projects[0]
expect(project.radius).toBeLessThan(700)
expect(
new Set(project.worktrees.map((worktree) => worktree.x.toFixed(3))).size
).toBeGreaterThan(12)
expect(
new Set(project.worktrees.map((worktree) => worktree.y.toFixed(3))).size
).toBeGreaterThan(12)
for (const [index, worktree] of project.worktrees.entries()) {
for (const other of project.worktrees.slice(index + 1)) {
expect(Math.hypot(worktree.x - other.x, worktree.y - other.y)).toBeGreaterThanOrEqual(
worktree.radius + other.radius + AGENT_MAP_WORKTREE_GAP - 0.001
)
}
}
})
it('uses one agent size while retaining elapsed duration', () => {
const finished = card({
startedAt: NOW - 30 * 60_000,
finishedAt: NOW - 20 * 60_000
})
const layout = deriveAgentMapLayout(
[
card({ paneKey: 'just-started', startedAt: NOW }),
card({ paneKey: 'long-running', startedAt: NOW - 24 * 60 * 60_000 })
],
NOW
)
expect(layout.projects[0].worktrees[0].agents.map((agent) => agent.radius)).toEqual([
AGENT_MAP_AGENT_RADIUS,
AGENT_MAP_AGENT_RADIUS
])
expect(agentMapDurationMinutes(finished, NOW)).toBe(10)
})
it('maps acknowledged completions to done-seen independently from elapsed time', () => {
for (const dotState of ['working', 'blocked', 'waiting', 'idle'] as const) {
expect(agentMapNodeStatus(card({ dotState }))).toBe(dotState)
}
expect(agentMapNodeStatus(card({ dotState: 'working', workingMode: 'monitoring' }))).toBe(
'monitoring'
)
expect(agentMapNodeStatus(card({ dotState: 'done', unseen: true }))).toBe('done')
expect(agentMapNodeStatus(card({ dotState: 'done', unseen: false }))).toBe('done-seen')
const shortBlocked = card({ dotState: 'blocked', startedAt: NOW - 60_000 })
const longBlocked = card({ dotState: 'blocked', startedAt: NOW - 45 * 60_000 })
expect(agentMapNodeStatus(shortBlocked)).toBe(agentMapNodeStatus(longBlocked))
})
it('marks only operationally quiet workspaces for semantic aggregation', () => {
const quiet = deriveAgentMapLayout(
Array.from({ length: 5 }, (_, index) =>
card({ paneKey: `quiet-${index}`, dotState: index === 0 ? 'done' : 'idle' })
),
NOW
)
const active = deriveAgentMapLayout([card({ paneKey: 'active', dotState: 'working' })], NOW)
const unseenDone = deriveAgentMapLayout(
Array.from({ length: 5 }, (_, index) =>
card({ paneKey: `done-${index}`, dotState: 'done', unseen: true })
),
NOW
)
expect(quiet.projects[0].worktrees[0].quiet).toBe(true)
expect(active.projects[0].worktrees[0].quiet).toBe(false)
expect(unseenDone.projects[0].worktrees[0].quiet).toBe(false)
})
it('places hundreds of agents in one workspace without overlap', () => {
const layout = deriveAgentMapLayout(
Array.from({ length: 400 }, (_, index) => card({ paneKey: `agent-${index}` })),
NOW
)
const worktree = layout.projects[0].worktrees[0]
expect(worktree.agents).toHaveLength(400)
let minimumDistance = Number.POSITIVE_INFINITY
for (const [index, agent] of worktree.agents.entries()) {
expect(Math.hypot(agent.x - worktree.x, agent.y - worktree.y) + agent.radius).toBeLessThan(
worktree.radius
)
for (const other of worktree.agents.slice(index + 1)) {
minimumDistance = Math.min(
minimumDistance,
Math.hypot(agent.x - other.x, agent.y - other.y)
)
}
}
expect(minimumDistance).toBeGreaterThanOrEqual(AGENT_MAP_AGENT_RADIUS * 2)
})
it('keeps deeply nested spawn lineage finite without recursive stack growth', () => {
const layout = deriveAgentMapLayout(
Array.from({ length: 5_000 }, (_, index) =>
card({
paneKey: `agent-${index.toString().padStart(4, '0')}`,
parentPaneKey:
index === 0 ? undefined : `agent-${(index - 1).toString().padStart(4, '0')}`
})
),
NOW
)
const worktree = layout.projects[0].worktrees[0]
const agents = new Map(worktree.agents.map((agent) => [agent.card.paneKey, agent]))
expect(worktree.agents).toHaveLength(5_000)
expect(Number.isFinite(worktree.radius)).toBe(true)
expect(worktree.radius).toBeLessThan(1_000_000)
for (const agent of worktree.agents) {
if (agent.card.parentPaneKey) {
expect(agent.y).toBeGreaterThan(agents.get(agent.card.parentPaneKey)!.y)
}
}
})
it('wraps very large spawn fanout without overlap', () => {
const layout = deriveAgentMapLayout(
[
card({ paneKey: 'parent' }),
...Array.from({ length: 300 }, (_, index) =>
card({ paneKey: `child-${index}`, parentPaneKey: 'parent' })
)
],
NOW
)
const worktree = layout.projects[0].worktrees[0]
const parent = worktree.agents.find((agent) => agent.card.paneKey === 'parent')!
const children = worktree.agents.filter((agent) => agent.card.parentPaneKey === 'parent')
let minimumDistance = Number.POSITIVE_INFINITY
expect(children.every((child) => child.y > parent.y)).toBe(true)
expect(worktree.radius).toBeLessThan(1_000)
for (const [index, child] of children.entries()) {
for (const other of children.slice(index + 1)) {
minimumDistance = Math.min(
minimumDistance,
Math.hypot(child.x - other.x, child.y - other.y)
)
}
}
expect(minimumDistance).toBeGreaterThanOrEqual(AGENT_MAP_AGENT_RADIUS * 2)
})
})
@@ -1,325 +0,0 @@
import type * as DashboardSnapshotTypes from '../../../../shared/dashboard-snapshot'
import { placeAgentMapAgents } from './agent-map-agent-placement'
import { layoutAgentMapLineage } from './agent-map-lineage-layout'
import { refreshAgentMapMetadata } from './agent-map-layout-metadata'
import {
agentMapDurationMinutes,
agentMapNodeStatus,
agentMapQuietCount,
emptyAgentMapStatusCounts,
type AgentMapNodeStatus,
type AgentMapStatusCounts
} from './agent-map-node-metadata'
import { placeAgentMapProjects } from './agent-map-project-placement'
import { selectAgentMapSpawnParentContainer } from './agent-map-spawn-clustering'
import {
agentMapCardTopologyIdentity,
agentMapWorkspaceIdentity,
agentMapWorkspaceTopologyIdentity,
agentMapWorktreeIdentity,
agentMapWorktreeIdentityFromParts
} from './agent-map-workspace-identity'
import { layoutAgentMapWorktreeLineage } from './agent-map-worktree-lineage-layout'
import { agentMapWorktreeHost } from './agent-map-worktree-host'
type DashboardCard = DashboardSnapshotTypes.DashboardCard
type DashboardWorkspace = DashboardSnapshotTypes.DashboardWorkspace
export { AGENT_MAP_WORKTREE_GAP } from './agent-map-worktree-packing'
export { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata'
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'
export type AgentMapMotionState = 'entering' | 'exiting'
const PROJECT_PADDING = 12
const WORLD_MARGIN = 32
const RING_CONTENT_OFFSET = AGENT_MAP_RING_HEADER_HEIGHT / 2
export type AgentMapAgentNode = {
card: DashboardCard
x: number
y: number
radius: number
durationMinutes: number
status: AgentMapNodeStatus
motionState?: AgentMapMotionState
}
export type AgentMapWorktreeRing = {
id: string
parentId?: string
/** Layout-only parent chosen from agent spawn edges; does not imply workspace lineage. */
clusterParentId?: string
worktreeId: string
executionHostId: DashboardCard['executionHostId']
hostKind?: DashboardCard['hostKind']
hostLabel?: string
name: string
workspaceKind: NonNullable<DashboardCard['workspaceKind']>
x: number
y: number
radius: number
agents: AgentMapAgentNode[]
statusCounts: AgentMapStatusCounts
quiet: boolean
motionState?: AgentMapMotionState
}
export type AgentMapProjectRing = {
id: string
name: string
x: number
y: number
radius: number
worktrees: AgentMapWorktreeRing[]
agentCount: number
motionState?: AgentMapMotionState
}
export type AgentMapLayout = {
projects: AgentMapProjectRing[]
width: number
height: number
topologyKey: string
}
export type AgentMapLayoutCache = {
topologyKey: string
geometry: AgentMapLayout
packingGeneration: number
}
type LocalWorktree = Omit<AgentMapWorktreeRing, 'x' | 'y'> & { x: number; y: number }
type LocalProject = Omit<AgentMapProjectRing, 'x' | 'y' | 'worktrees'> & {
x: number
y: number
clusterParentId?: string
worktrees: LocalWorktree[]
}
function compareStable(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0
}
export function agentMapTopologyKey(
cards: DashboardCard[],
workspaces: DashboardWorkspace[] = []
): string {
return [
...cards.map((card) => `a:${agentMapCardTopologyIdentity(card)}`),
...workspaces.map((workspace) => `w:${agentMapWorkspaceTopologyIdentity(workspace)}`)
]
.sort(compareStable)
.join('|')
}
export function shouldAggregateAgentMapWorktree(
worktree: AgentMapWorktreeRing,
zoom: number,
allowAggregation = true
): boolean {
return (
allowAggregation &&
zoom < AGENT_MAP_AGGREGATE_ZOOM &&
worktree.quiet &&
worktree.agents.length > 3
)
}
function worktreeRadius(agentCount: number): number {
return Math.max(
52,
24 + Math.ceil(Math.sqrt(Math.max(1, agentCount))) * (AGENT_MAP_AGENT_RADIUS + 8)
)
}
function buildLocalWorktree(
id: string,
cards: DashboardCard[],
now: number,
workspace?: DashboardWorkspace
): LocalWorktree {
const lineageLayout = layoutAgentMapLineage(cards, AGENT_MAP_AGENT_RADIUS)
const contentRadius = lineageLayout?.radius ?? worktreeRadius(cards.length)
const radius = contentRadius + RING_CONTENT_OFFSET
const statusCounts = emptyAgentMapStatusCounts()
for (const card of cards) {
statusCounts[agentMapNodeStatus(card)] += 1
}
const host = agentMapWorktreeHost(cards, workspace)
const executionHostId = host.executionHostId
const parentWorktreeId = workspace?.parentWorktreeId ?? cards[0]?.parentWorktreeId
return {
id,
parentId: parentWorktreeId
? agentMapWorktreeIdentityFromParts(parentWorktreeId, executionHostId)
: undefined,
worktreeId: workspace?.worktreeId ?? cards[0]?.worktreeId ?? id,
...host,
name: workspace?.worktreeName ?? cards[0]?.worktreeName ?? id,
workspaceKind: workspace?.workspaceKind ?? cards[0]?.workspaceKind ?? 'worktree',
x: 0,
y: 0,
radius,
agents: (
lineageLayout?.agents.map(({ card, x, y }) => ({
card,
x,
y,
radius: AGENT_MAP_AGENT_RADIUS,
durationMinutes: agentMapDurationMinutes(card, now),
status: agentMapNodeStatus(card)
})) ??
placeAgentMapAgents({
worktreeId: id,
cards,
radius: contentRadius,
agentRadius: AGENT_MAP_AGENT_RADIUS,
now
})
).map((agent) => ({ ...agent, y: agent.y + RING_CONTENT_OFFSET })),
statusCounts,
quiet: agentMapQuietCount(statusCounts) === cards.length
}
}
function buildLocalProject(
id: string,
cards: DashboardCard[],
workspaces: DashboardWorkspace[],
cardsByPaneKey: ReadonlyMap<string, DashboardCard>,
now: number
): LocalProject {
const byWorktree = new Map<string, DashboardCard[]>()
for (const card of cards) {
const identity = agentMapWorktreeIdentity(card)
const current = byWorktree.get(identity)
if (current) {
current.push(card)
} else {
byWorktree.set(identity, [card])
}
}
const workspacesById = new Map(
workspaces.map((workspace) => [agentMapWorkspaceIdentity(workspace), workspace])
)
for (const workspaceId of workspacesById.keys()) {
if (!byWorktree.has(workspaceId)) {
byWorktree.set(workspaceId, [])
}
}
const positionedWorktrees = layoutAgentMapWorktreeLineage(
[...byWorktree.entries()]
.sort(([a], [b]) => compareStable(a, b))
.map(([worktreeId, worktreeCards]) => ({
...buildLocalWorktree(worktreeId, worktreeCards, now, workspacesById.get(worktreeId)),
clusterParentId: selectAgentMapSpawnParentContainer(
worktreeCards,
cardsByPaneKey,
agentMapWorktreeIdentity
)
}))
)
const contentRadius = Math.max(
84,
...positionedWorktrees.map(
(worktree) => Math.hypot(worktree.x, worktree.y) + worktree.radius + PROJECT_PADDING
)
)
const worktrees = positionedWorktrees.map((worktree) => ({
...worktree,
y: worktree.y + RING_CONTENT_OFFSET
}))
return {
id,
name: cards[0]?.repoName ?? workspaces[0]?.repoName ?? id,
x: 0,
y: 0,
clusterParentId: selectAgentMapSpawnParentContainer(
cards,
cardsByPaneKey,
(card) => card.repoId
),
radius: contentRadius + RING_CONTENT_OFFSET,
worktrees,
agentCount: cards.length
}
}
export function deriveAgentMapLayout(
cards: DashboardCard[],
now: number,
workspaces: DashboardWorkspace[] = []
): AgentMapLayout {
const topologyKey = agentMapTopologyKey(cards, workspaces)
if (cards.length === 0 && workspaces.length === 0) {
return { projects: [], width: 900, height: 560, topologyKey }
}
const byProject = new Map<string, { cards: DashboardCard[]; workspaces: DashboardWorkspace[] }>()
for (const card of cards) {
const current = byProject.get(card.repoId) ?? { cards: [], workspaces: [] }
current.cards.push(card)
byProject.set(card.repoId, current)
}
for (const workspace of workspaces) {
const current = byProject.get(workspace.repoId) ?? { cards: [], workspaces: [] }
current.workspaces.push(workspace)
byProject.set(workspace.repoId, current)
}
const cardsByPaneKey = new Map(cards.map((card) => [card.paneKey, card]))
const localProjects = [...byProject.entries()]
.sort(([a], [b]) => compareStable(a, b))
.map(([projectId, project]) =>
buildLocalProject(projectId, project.cards, project.workspaces, cardsByPaneKey, now)
)
const framed = placeAgentMapProjects(localProjects, 900, 560, WORLD_MARGIN)
const projects = framed.projects.map((project): AgentMapProjectRing => {
return {
...project,
worktrees: project.worktrees.map((worktree) => ({
...worktree,
x: project.x + worktree.x,
y: project.y + worktree.y,
agents: worktree.agents.map((agent) => ({
...agent,
x: project.x + worktree.x + agent.x,
y: project.y + worktree.y + agent.y
}))
}))
}
})
return { projects, width: framed.width, height: framed.height, topologyKey }
}
export function updateAgentMapLayout(
cache: AgentMapLayoutCache | null,
cards: DashboardCard[],
now: number,
workspaces: DashboardWorkspace[] = []
): { cache: AgentMapLayoutCache; layout: AgentMapLayout } {
const topologyKey = agentMapTopologyKey(cards, workspaces)
if (!cache || cache.topologyKey !== topologyKey) {
const geometry = deriveAgentMapLayout(cards, now, workspaces)
return {
cache: {
topologyKey,
geometry,
packingGeneration: (cache?.packingGeneration ?? 0) + 1
},
layout: geometry
}
}
const layout = refreshAgentMapMetadata(cache.geometry, cards, workspaces, now)
return { cache, layout }
}
@@ -1,136 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
agentMapDirectLineageChevronPath,
agentMapLineageChevronPath
} from './agent-map-lineage-chevron-path'
afterEach(() => {
vi.restoreAllMocks()
})
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(256)
})
it('keeps the same chevron pitch however far apart the nodes are', () => {
const pitches = [60, 200, 900].map((distance) => {
const tips = [
...agentMapDirectLineageChevronPath(
{ x: 0, y: 0, radius: 0 },
{ x: distance, y: 0, radius: 0 }
).matchAll(/M [-\d.]+ [-\d.]+ L ([-\d.]+) [-\d.]+ L/g)
].map((match) => Number(match[1]))
expect(tips.length).toBeGreaterThan(2)
return tips.slice(1).map((tip, index) => tip - tips[index])
})
expect(pitches.flat().every((pitch) => pitch === 8)).toBe(true)
})
it('keeps fixed pitch across degenerate and multi-segment paths', () => {
const path = agentMapLineageChevronPath([
{ x: 0, y: 0 },
{ x: 0, y: 0 },
{ x: 9, y: 0 },
{ x: 9, y: 23 },
{ x: 30, y: 23 }
])
const tips = [...path.matchAll(/M [-\d.]+ [-\d.]+ L ([-\d.]+) ([-\d.]+) L/g)].map((match) => ({
x: Number(match[1]),
y: Number(match[2])
}))
expect(tips).toEqual([
{ x: 6.5, y: 0 },
{ x: 9, y: 5.5 },
{ x: 9, y: 13.5 },
{ x: 9, y: 21.5 },
{ x: 15.5, y: 23 },
{ x: 23.5, y: 23 }
])
})
it('serves an unmoved edge from cache instead of rebuilding it', async () => {
vi.resetModules()
const { agentMapDirectLineageChevronPath: cachedPath } =
await import('./agent-map-lineage-chevron-path')
const parent = { x: 3, y: 5, radius: 20 }
const child = { x: 903, y: 5, radius: 20 }
const hypot = vi.spyOn(Math, 'hypot')
const first = cachedPath(parent, child)
expect(hypot).toHaveBeenCalled()
hypot.mockClear()
const second = cachedPath({ ...parent }, { ...child })
expect(hypot).not.toHaveBeenCalled()
expect(second).toBe(first)
})
it('keeps 512 recently used paths and evicts the least-recently-used path', async () => {
vi.resetModules()
const { agentMapDirectLineageChevronPath: cachedPath } =
await import('./agent-map-lineage-chevron-path')
const edge = (x: number) =>
[
{ x, y: 1_000, radius: 2 },
{ x, y: 1_200, radius: 2 }
] as const
for (let i = 0; i < 512; i += 1) {
cachedPath(...edge(i))
}
const hypot = vi.spyOn(Math, 'hypot')
cachedPath(...edge(0))
expect(hypot).not.toHaveBeenCalled()
cachedPath(...edge(512))
hypot.mockClear()
cachedPath(...edge(1))
expect(hypot).toHaveBeenCalled()
hypot.mockClear()
cachedPath(...edge(0))
expect(hypot).not.toHaveBeenCalled()
})
})
@@ -1,125 +0,0 @@
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 = 256
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))
)
// Fixed pitch, centered run: spacing must read identically on a short link and a long
// one. Dividing the length by the count instead stretched the gaps as nodes moved apart.
const firstDistance = (totalLength - (chevronCount - 1) * CHEVRON_SPACING) / 2
const commands: string[] = []
let segmentIndex = 0
let segmentStartDistance = 0
for (let index = 0; index < chevronCount; index += 1) {
const distance = firstDistance + index * CHEVRON_SPACING
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(' ')
}
function buildDirectLineageChevronPath(
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 }
])
}
// Keyed on world coordinates, which a zoom gesture never changes — so the scene's
// per-frame rerender reuses every path instead of rebuilding kilobytes of `d` at
// 60fps. Only enter/exit motion, which really does move nodes, misses. LRU-bounded
// because a removed agent's key is never revisited.
const MAX_CACHED_LINEAGE_PATHS = 512
const lineagePathCache = new Map<string, string>()
export function agentMapDirectLineageChevronPath(
parent: AgentMapLineageNode,
child: AgentMapLineageNode
): string {
const key = `${parent.x},${parent.y},${parent.radius},${child.x},${child.y},${child.radius}`
const cached = lineagePathCache.get(key)
if (cached !== undefined) {
lineagePathCache.delete(key)
lineagePathCache.set(key, cached)
return cached
}
const path = buildDirectLineageChevronPath(parent, child)
lineagePathCache.set(key, path)
while (lineagePathCache.size > MAX_CACHED_LINEAGE_PATHS) {
const oldest = lineagePathCache.keys().next().value
if (oldest === undefined) {
break
}
lineagePathCache.delete(oldest)
}
return path
}
@@ -1,224 +0,0 @@
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { packAgentMapWorktrees } from './agent-map-worktree-packing'
const HORIZONTAL_GAP = 54
const VERTICAL_GAP = 58
const FAMILY_PADDING = 8
const WORKTREE_PADDING = 6
const COMPACT_FANOUT_THRESHOLD = 12
const MAX_EXACT_LINEAGE_AGENTS = 256
export type AgentMapLineagePosition = {
card: DashboardCard
x: number
y: number
}
type AgentMapAgentFamily = {
id: string
x: number
y: number
radius: number
agents: AgentMapLineagePosition[]
}
function compareStable(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0
}
function encloseFamily(
id: string,
agents: AgentMapLineagePosition[],
nodeRadius: number
): AgentMapAgentFamily {
let left = Number.POSITIVE_INFINITY
let right = Number.NEGATIVE_INFINITY
let top = Number.POSITIVE_INFINITY
let bottom = Number.NEGATIVE_INFINITY
for (const agent of agents) {
left = Math.min(left, agent.x - nodeRadius)
right = Math.max(right, agent.x + nodeRadius)
top = Math.min(top, agent.y - nodeRadius)
bottom = Math.max(bottom, agent.y + nodeRadius)
}
const centerX = (left + right) / 2
const centerY = (top + bottom) / 2
let radius = 0
for (const agent of agents) {
agent.x -= centerX
agent.y -= centerY
radius = Math.max(radius, Math.hypot(agent.x, agent.y) + nodeRadius + FAMILY_PADDING)
}
return { id, x: 0, y: 0, radius, agents }
}
function buildCompactFanoutFamily(
root: DashboardCard,
children: DashboardCard[],
nodeRadius: number,
emitted: Set<string>
): AgentMapAgentFamily {
const columns = Math.ceil(Math.sqrt(children.length))
const width = (Math.min(columns, children.length) - 1) * HORIZONTAL_GAP
const agents: AgentMapLineagePosition[] = [{ card: root, x: 0, y: 0 }]
emitted.add(root.paneKey)
for (const [index, child] of children.entries()) {
emitted.add(child.paneKey)
agents.push({
card: child,
x: (index % columns) * HORIZONTAL_GAP - width / 2,
y: (Math.floor(index / columns) + 1) * VERTICAL_GAP
})
}
return encloseFamily(root.paneKey, agents, nodeRadius)
}
function buildFamily(
root: DashboardCard,
childrenByParent: ReadonlyMap<string, DashboardCard[]>,
nodeRadius: number,
emitted: Set<string>
): AgentMapAgentFamily {
const agents: AgentMapLineagePosition[] = []
let leafIndex = 0
const rootChildren = (childrenByParent.get(root.paneKey) ?? []).filter(
(child) => !emitted.has(child.paneKey)
)
if (
rootChildren.length >= COMPACT_FANOUT_THRESHOLD &&
rootChildren.every((child) => (childrenByParent.get(child.paneKey) ?? []).length === 0)
) {
return buildCompactFanoutFamily(root, rootChildren, nodeRadius, emitted)
}
const placeSubtree = (
card: DashboardCard,
depth: number,
ancestors: ReadonlySet<string>
): number => {
if (ancestors.has(card.paneKey) || emitted.has(card.paneKey)) {
return leafIndex++ * HORIZONTAL_GAP
}
emitted.add(card.paneKey)
const nextAncestors = new Set(ancestors)
nextAncestors.add(card.paneKey)
const children = (childrenByParent.get(card.paneKey) ?? []).filter(
(child) => !nextAncestors.has(child.paneKey) && !emitted.has(child.paneKey)
)
const childXs = children.map((child) => placeSubtree(child, depth + 1, nextAncestors))
const x =
childXs.length > 0
? (Math.min(...childXs) + Math.max(...childXs)) / 2
: leafIndex++ * HORIZONTAL_GAP
agents.push({ card, x, y: depth * VERTICAL_GAP })
return x
}
placeSubtree(root, 0, new Set())
return encloseFamily(root.paneKey, agents, nodeRadius)
}
function layoutBoundedLineage(
sorted: DashboardCard[],
childrenByParent: ReadonlyMap<string, DashboardCard[]>,
childPaneKeys: ReadonlySet<string>,
nodeRadius: number
): { agents: AgentMapLineagePosition[]; radius: number } {
const levels: DashboardCard[][] = []
const emitted = new Set<string>()
const roots = sorted.filter((card) => !childPaneKeys.has(card.paneKey))
for (const seed of [...roots, ...sorted]) {
if (emitted.has(seed.paneKey)) {
continue
}
const stack = [{ card: seed, depth: 0 }]
while (stack.length > 0) {
const entry = stack.pop()!
if (emitted.has(entry.card.paneKey)) {
continue
}
emitted.add(entry.card.paneKey)
const level = levels[entry.depth] ?? []
levels[entry.depth] = level
level.push(entry.card)
const children = childrenByParent.get(entry.card.paneKey) ?? []
for (let index = children.length - 1; index >= 0; index -= 1) {
if (!emitted.has(children[index].paneKey)) {
stack.push({ card: children[index], depth: entry.depth + 1 })
}
}
}
}
const agents: AgentMapLineagePosition[] = []
let rowIndex = 0
for (const level of levels) {
const columns = Math.ceil(Math.sqrt(level.length))
for (let rowStart = 0; rowStart < level.length; rowStart += columns) {
const row = level.slice(rowStart, rowStart + columns)
const width = (row.length - 1) * HORIZONTAL_GAP
for (const [index, card] of row.entries()) {
agents.push({ card, x: index * HORIZONTAL_GAP - width / 2, y: rowIndex * VERTICAL_GAP })
}
rowIndex += 1
}
}
const family = encloseFamily(sorted[0].paneKey, agents, nodeRadius)
family.agents.sort((a, b) => compareStable(a.card.paneKey, b.card.paneKey))
return { agents: family.agents, radius: Math.max(52, family.radius + WORKTREE_PADDING) }
}
export function layoutAgentMapLineage(
cards: DashboardCard[],
nodeRadius: number
): { agents: AgentMapLineagePosition[]; radius: number } | null {
const sorted = [...cards].sort((a, b) => compareStable(a.paneKey, b.paneKey))
const cardsByPaneKey = new Map(sorted.map((card) => [card.paneKey, card]))
const childrenByParent = new Map<string, DashboardCard[]>()
const childPaneKeys = new Set<string>()
for (const card of sorted) {
const parentPaneKey = card.parentPaneKey
if (!parentPaneKey || parentPaneKey === card.paneKey || !cardsByPaneKey.has(parentPaneKey)) {
continue
}
childPaneKeys.add(card.paneKey)
childrenByParent.set(parentPaneKey, [...(childrenByParent.get(parentPaneKey) ?? []), card])
}
if (childPaneKeys.size === 0) {
return null
}
if (sorted.length > MAX_EXACT_LINEAGE_AGENTS) {
return layoutBoundedLineage(sorted, childrenByParent, childPaneKeys, nodeRadius)
}
const emitted = new Set<string>()
const roots = sorted.filter((card) => !childPaneKeys.has(card.paneKey))
const families: AgentMapAgentFamily[] = []
for (const root of roots) {
if (!emitted.has(root.paneKey)) {
families.push(buildFamily(root, childrenByParent, nodeRadius, emitted))
}
}
for (const card of sorted) {
if (!emitted.has(card.paneKey)) {
families.push(buildFamily(card, childrenByParent, nodeRadius, emitted))
}
}
const packed = packAgentMapWorktrees(families)
return {
agents: packed
.flatMap((family) =>
family.agents.map((agent) => ({
...agent,
x: family.x + agent.x,
y: family.y + agent.y
}))
)
.sort((a, b) => compareStable(a.card.paneKey, b.card.paneKey)),
radius: Math.max(
52,
...packed.map((family) => Math.hypot(family.x, family.y) + family.radius + WORKTREE_PADDING)
)
}
}
@@ -1,42 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { deriveAgentMapLayout } from './agent-map-layout'
import { navigableAgentMapAgents } from './agent-map-navigation'
const NOW = 2_000_000_000
function card(paneKey: string, worktreeId: string, idle: boolean): DashboardCard {
return {
paneKey,
ptyId: `pty-${paneKey}`,
agentType: 'codex',
bucket: idle ? 'idle' : 'working',
dotState: idle ? 'idle' : 'working',
task: '',
repoId: 'repo-1',
worktreeId,
tabId: `tab-${paneKey}`,
leafId: `leaf-${paneKey}`,
repoName: 'Orca',
worktreeName: worktreeId,
startedAt: NOW - 60_000,
finishedAt: idle ? NOW - 30_000 : null,
stateChangedAt: NOW - 30_000,
unseen: false
}
}
describe('agent map keyboard navigation visibility', () => {
it('excludes every aggregated worktree and restores a selected quiet worktree', () => {
const quietCards = Array.from({ length: 5 }, (_, index) =>
card(`quiet-${index}`, 'quiet-worktree', true)
)
const active = card('active', 'active-worktree', false)
const layout = deriveAgentMapLayout([...quietCards, active], NOW)
expect(
navigableAgentMapAgents(layout, 1, true, null).map((agent) => agent.card.paneKey)
).toEqual(['active'])
expect(navigableAgentMapAgents(layout, 1, true, 'quiet-0')).toHaveLength(6)
})
})
@@ -1,53 +0,0 @@
import type { AgentMapAgentNode, AgentMapLayout } from './agent-map-layout'
import { shouldAggregateAgentMapWorktree } from './agent-map-layout'
type Direction = { x: number; y: number }
export function agentMapAgents(layout: AgentMapLayout): AgentMapAgentNode[] {
return layout.projects.flatMap((project) =>
project.worktrees.flatMap((worktree) => worktree.agents)
)
}
export function navigableAgentMapAgents(
layout: AgentMapLayout,
zoom: number,
allowAggregation: boolean,
selectedPaneKey: string | null
): AgentMapAgentNode[] {
return layout.projects.flatMap((project) =>
project.worktrees.flatMap((worktree) => {
const containsSelection = worktree.agents.some(
(agent) => agent.card.paneKey === selectedPaneKey
)
return !containsSelection && shouldAggregateAgentMapWorktree(worktree, zoom, allowAggregation)
? []
: worktree.agents
})
)
}
export function nextDirectionalAgent(
current: AgentMapAgentNode,
agents: AgentMapAgentNode[],
direction: Direction
): AgentMapAgentNode | null {
let best: { agent: AgentMapAgentNode; score: number } | null = null
for (const candidate of agents) {
if (candidate.card.paneKey === current.card.paneKey) {
continue
}
const dx = candidate.x - current.x
const dy = candidate.y - current.y
const forward = dx * direction.x + dy * direction.y
if (forward <= 0) {
continue
}
const sideways = Math.abs(dx * direction.y - dy * direction.x)
const score = forward + sideways * 2
if (!best || score < best.score) {
best = { agent: candidate, score }
}
}
return best?.agent ?? null
}
@@ -1,163 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import {
AGENT_MAP_MAX_CONCURRENT_STATUS_FLARES,
AGENT_MAP_STATUS_FLARE_MS,
agentMapRecentFlareStatus,
agentMapNodeStatus,
agentMapQuietCount,
emptyAgentMapStatusCounts,
selectAgentMapRecentFlareStatuses
} from './agent-map-node-metadata'
const NOW = 2_000_000_000
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
return {
paneKey: 'pane-1',
ptyId: 'pty-1',
agentType: 'codex',
bucket: 'done',
dotState: 'done',
task: '',
repoId: 'repo-1',
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
repoName: 'Orca',
worktreeName: 'Agent map',
startedAt: NOW - 60_000,
finishedAt: NOW - 30_000,
stateChangedAt: NOW - 30_000,
unseen: false,
hostKind: 'local',
...overrides
}
}
describe('agentMapNodeStatus', () => {
it('splits a finish by whether it has been acknowledged', () => {
expect(agentMapNodeStatus(card({ unseen: true }))).toBe('done')
expect(agentMapNodeStatus(card({ unseen: false }))).toBe('done-seen')
})
it('never collapses an acknowledged finish into idle', () => {
// The shared `dashboardCardDisplayState` does exactly that for bucket counts, which
// would make finished-but-unlanded work indistinguishable from a workspace that
// never ran. The map keeps them apart.
expect(agentMapNodeStatus(card({ unseen: false }))).not.toBe('idle')
expect(agentMapNodeStatus(card({ bucket: 'idle', dotState: 'idle', finishedAt: null }))).toBe(
'idle'
)
})
it('leaves every non-done state on the shared display state', () => {
for (const dotState of ['working', 'blocked', 'waiting', 'idle'] as const) {
expect(agentMapNodeStatus(card({ dotState, unseen: true }))).toBe(dotState)
expect(agentMapNodeStatus(card({ dotState, unseen: false }))).toBe(dotState)
}
})
})
describe('agentMapRecentFlareStatus', () => {
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('flares on the transition into done, measured against the wall clock', () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
const justFinished = card({ dotState: 'done', unseen: true, stateChangedAt: NOW })
expect(agentMapRecentFlareStatus(justFinished)).toBe('done')
vi.setSystemTime(NOW + AGENT_MAP_STATUS_FLARE_MS - 1)
expect(agentMapRecentFlareStatus(justFinished)).toBe('done')
vi.setSystemTime(NOW + AGENT_MAP_STATUS_FLARE_MS + 1)
expect(agentMapRecentFlareStatus(justFinished)).toBeNull()
})
it('flares on the transition into a question', () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
expect(
agentMapRecentFlareStatus(
card({ bucket: 'attention', dotState: 'waiting', unseen: true, stateChangedAt: NOW })
)
).toBe('waiting')
})
it('does not reuse an earlier finish timestamp for a question with unknown timing', () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
expect(
agentMapRecentFlareStatus(
card({ dotState: 'waiting', stateChangedAt: 0, finishedAt: NOW, unseen: true })
)
).toBeNull()
})
it('samples the wall clock once and caps mixed bursty fleet updates', () => {
const clock = vi.spyOn(Date, 'now').mockReturnValue(NOW)
const selected = selectAgentMapRecentFlareStatuses(
Array.from({ length: 200 }, (_, index) =>
card({
paneKey: `pane-${index}`,
bucket: index % 2 === 0 ? 'done' : 'attention',
dotState: index % 2 === 0 ? 'done' : 'waiting',
unseen: true,
stateChangedAt: NOW - index
})
)
)
expect(clock).toHaveBeenCalledOnce()
expect(selected.size).toBe(AGENT_MAP_MAX_CONCURRENT_STATUS_FLARES)
expect([...selected]).toEqual([
['pane-0', 'done'],
['pane-1', 'waiting'],
['pane-2', 'done'],
['pane-3', 'waiting']
])
})
it('does not flare status changes from before this session', () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
expect(
agentMapRecentFlareStatus(
card({ dotState: 'done', unseen: true, stateChangedAt: NOW - 60_000 })
)
).toBeNull()
// A clock skew that puts the finish in the future must not latch a flare on forever.
expect(
agentMapRecentFlareStatus(
card({ dotState: 'done', unseen: true, stateChangedAt: NOW + 5_000 })
)
).toBeNull()
})
it('never flares a state that is not a question or unread finish', () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
expect(
agentMapRecentFlareStatus(card({ dotState: 'done', unseen: false, stateChangedAt: NOW }))
).toBeNull()
expect(
agentMapRecentFlareStatus(card({ dotState: 'working', unseen: true, stateChangedAt: NOW }))
).toBeNull()
})
})
describe('agentMapQuietCount', () => {
it('treats an acknowledged finish as quiet so label declutter is unchanged', () => {
expect(agentMapQuietCount({ ...emptyAgentMapStatusCounts(), 'done-seen': 3, idle: 2 })).toBe(5)
})
it('keeps an unread finish loud', () => {
expect(agentMapQuietCount({ ...emptyAgentMapStatusCounts(), done: 4 })).toBe(0)
expect(agentMapQuietCount({ ...emptyAgentMapStatusCounts(), working: 4 })).toBe(0)
})
})
@@ -1,101 +0,0 @@
import {
dashboardCardDisplayState,
type DashboardCard,
type DashboardCardDisplayState,
type DashboardCardDotState
} from '../../../../shared/dashboard-snapshot'
/** Map-only refinement of the shared dot state. `dashboardCardDisplayState` folds an
* acknowledged finish into `idle`, which is right for bucket counts but loses the one
* distinction the map exists to show: finished-and-unread vs finished-and-still-yours.
* Kept local so `DashboardCardDotState` — which crosses the pop-out bridge — is unchanged. */
export type AgentMapNodeStatus = DashboardCardDisplayState | 'done-seen'
export function agentMapDurationMinutes(card: DashboardCard, now: number): number {
if (!Number.isFinite(card.startedAt) || card.startedAt <= 0) {
return 0
}
const end = card.finishedAt && card.finishedAt >= card.startedAt ? card.finishedAt : now
return Math.max(0, (end - card.startedAt) / 60_000)
}
export function agentMapNodeStatus(card: DashboardCard): AgentMapNodeStatus {
if (card.dotState === 'done') {
return card.unseen ? 'done' : 'done-seen'
}
return dashboardCardDisplayState(card)
}
export type AgentMapFlareStatus = Extract<DashboardCardDotState, 'waiting' | 'done'>
/** How long a fresh question or finish keeps its one-shot flare. Long enough to catch
* the eye from across the map, short enough that a busy fleet is never permanently
* animating. Must stay in step with the `agent-map-status-flare` duration in
* `agent-map.css`, or the element unmounts mid-ripple. */
export const AGENT_MAP_STATUS_FLARE_MS = 1_400
// Static status emphasis remains uncapped; this bounds animated SVG paint only.
export const AGENT_MAP_MAX_CONCURRENT_STATUS_FLARES = 4
function agentMapFlareChangedAt(card: DashboardCard): number {
return card.stateChangedAt || (card.dotState === 'done' ? card.finishedAt : 0) || 0
}
/** Uses wall time because the map's relative-timestamp clock advances only every 30s. */
export function agentMapRecentFlareStatus(
card: DashboardCard,
currentTime = Date.now()
): AgentMapFlareStatus | null {
if (card.dotState !== 'waiting' && (card.dotState !== 'done' || !card.unseen)) {
return null
}
const changedAt = agentMapFlareChangedAt(card)
if (changedAt <= 0) {
return null
}
const elapsed = currentTime - changedAt
// A fleet that loads with old status changes must not flare all at once.
return elapsed >= 0 && elapsed < AGENT_MAP_STATUS_FLARE_MS ? card.dotState : null
}
/** Selects only the freshest question/finish changes so bursts cannot animate the fleet. */
export function selectAgentMapRecentFlareStatuses(
cards: readonly DashboardCard[]
): ReadonlyMap<string, AgentMapFlareStatus> {
const currentTime = Date.now()
const recent: { paneKey: string; changedAt: number; status: AgentMapFlareStatus }[] = []
for (const card of cards) {
const status = agentMapRecentFlareStatus(card, currentTime)
if (!status) {
continue
}
const changedAt = agentMapFlareChangedAt(card)
const index = recent.findIndex(
(item) =>
changedAt > item.changedAt || (changedAt === item.changedAt && card.paneKey < item.paneKey)
)
if (index === -1) {
if (recent.length < AGENT_MAP_MAX_CONCURRENT_STATUS_FLARES) {
recent.push({ paneKey: card.paneKey, changedAt, status })
}
continue
}
recent.splice(index, 0, { paneKey: card.paneKey, changedAt, status })
if (recent.length > AGENT_MAP_MAX_CONCURRENT_STATUS_FLARES) {
recent.pop()
}
}
return new Map(recent.map((item) => [item.paneKey, item.status]))
}
export type AgentMapStatusCounts = Record<AgentMapNodeStatus, number>
export function emptyAgentMapStatusCounts(): AgentMapStatusCounts {
return { working: 0, monitoring: 0, blocked: 0, waiting: 0, done: 0, 'done-seen': 0, idle: 0 }
}
/** Finished work you have already opened is still yours to land, but it is not asking for
* attention. Counting it as quiet keeps ring aggregation and label declutter behaving
* exactly as they did when an acknowledged finish rendered as plain idle. */
export function agentMapQuietCount(counts: AgentMapStatusCounts): number {
return counts.idle + counts['done-seen']
}
@@ -1,17 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { agentMapStatusLabel } from './agent-map-node-presentation'
vi.mock('@/i18n/i18n', () => ({
translate: (key: string, fallback: string) => `${key}:${fallback}`
}))
describe('agentMapStatusLabel', () => {
it('localizes the map-only acknowledged completion state', () => {
expect(agentMapStatusLabel('done-seen')).toBe('dashboardPopout.map.status.doneSeen:Done, seen')
})
it('keeps shared agent states on their existing labels', () => {
expect(agentMapStatusLabel('working')).toBe('Working')
expect(agentMapStatusLabel('done')).toBe('Done')
})
})
@@ -1,44 +0,0 @@
import { agentStateLabel } from '@/components/AgentStateDot'
import { translate } from '@/i18n/i18n'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { agentMapDirectLineageChevronPath } from './agent-map-lineage-chevron-path'
import type { AgentMapAgentNode } from './agent-map-layout'
import type { AgentMapNodeStatus } from './agent-map-node-metadata'
/** Lives here, not in `agent-map-node-metadata`: `agentStateLabel` drags in React and
* lucide-react, and that module is on the layout and filter paths, which must stay
* free of component imports. `agentStateLabel` is shared with every other dot
* renderer, so the map's extra state gets its label here rather than widening
* `AgentDotState`. */
export function agentMapStatusLabel(status: AgentMapNodeStatus): string {
return status === 'done-seen'
? translate('dashboardPopout.map.status.doneSeen', 'Done, seen')
: agentStateLabel(status)
}
export function formatDuration(minutes: number): string {
if (minutes < 1) {
return translate('dashboardPopout.card.time.justNow', 'just now')
}
if (minutes < 60) {
return translate('dashboardPopout.card.time.minutes', '{{count}}m', {
count: Math.floor(minutes)
})
}
return translate('dashboardPopout.card.time.hours', '{{count}}h', {
count: Math.floor(minutes / 60)
})
}
export function lineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): string {
return agentMapDirectLineageChevronPath(parent, child)
}
export function agentName(card: DashboardCard): string {
return card.conversationName ?? (card.task.trim() || card.agentType)
}
export function agentMapAttentionMarkerScale(mapScale: number): number {
const inverseScale = 1 / Math.max(mapScale, 0.001)
return Math.max(1, inverseScale ** 0.72, inverseScale * 0.5)
}
@@ -1,94 +0,0 @@
export const AGENT_MAP_WORKTREE_GAP = 8
export const AGENT_MAP_PACKING_SCORE_TOLERANCE = 0.001
const PACKING_GRID_SIZE = 128
export type AgentMapPackableCircle = {
id: string
x: number
y: number
radius: number
}
type PackingSpatialGrid = {
cells: Map<number, Map<number, AgentMapPackableCircle[]>>
cellSize: number
}
export type AgentMapPackingSpatialIndex = Map<number, PackingSpatialGrid>
function packingGridLevel(radius: number): number {
return Math.max(
0,
Math.ceil(Math.log2((radius * 2 + AGENT_MAP_WORKTREE_GAP) / PACKING_GRID_SIZE))
)
}
export function addAgentMapPackingCircle(
index: AgentMapPackingSpatialIndex,
circle: AgentMapPackableCircle
): void {
const level = packingGridLevel(circle.radius)
let grid = index.get(level)
if (!grid) {
grid = { cells: new Map(), cellSize: PACKING_GRID_SIZE * 2 ** level }
index.set(level, grid)
}
const left = Math.floor((circle.x - circle.radius) / grid.cellSize)
const right = Math.floor((circle.x + circle.radius) / grid.cellSize)
const top = Math.floor((circle.y - circle.radius) / grid.cellSize)
const bottom = Math.floor((circle.y + circle.radius) / grid.cellSize)
for (let x = left; x <= right; x += 1) {
let column = grid.cells.get(x)
if (!column) {
column = new Map()
grid.cells.set(x, column)
}
for (let y = top; y <= bottom; y += 1) {
const cell = column.get(y)
if (cell) {
cell.push(circle)
} else {
column.set(y, [circle])
}
}
}
}
export function agentMapPackingCircleOverlaps(
candidate: Pick<AgentMapPackableCircle, 'x' | 'y' | 'radius'>,
index: AgentMapPackingSpatialIndex
): boolean {
const searchRadius = candidate.radius + AGENT_MAP_WORKTREE_GAP
const checked = new Set<AgentMapPackableCircle>()
for (const grid of index.values()) {
const left = Math.floor((candidate.x - searchRadius) / grid.cellSize)
const right = Math.floor((candidate.x + searchRadius) / grid.cellSize)
const top = Math.floor((candidate.y - searchRadius) / grid.cellSize)
const bottom = Math.floor((candidate.y + searchRadius) / grid.cellSize)
for (let x = left; x <= right; x += 1) {
const column = grid.cells.get(x)
if (!column) {
continue
}
for (let y = top; y <= bottom; y += 1) {
for (const circle of column.get(y) ?? []) {
if (checked.has(circle)) {
continue
}
checked.add(circle)
if (
Math.hypot(candidate.x - circle.x, candidate.y - circle.y) <
candidate.radius +
circle.radius +
AGENT_MAP_WORKTREE_GAP -
AGENT_MAP_PACKING_SCORE_TOLERANCE
) {
return true
}
}
}
}
}
return false
}
@@ -1,50 +0,0 @@
import { layoutAgentMapWorktreeLineage } from './agent-map-worktree-lineage-layout'
const PROJECT_GAP = 32
type ProjectCircle = {
id: string
x: number
y: number
radius: number
clusterParentId?: string
}
function placeUnlinkedProjects<T extends ProjectCircle>(projects: T[]): T[] {
let cursorX = 0
return projects.map((project) => {
const positioned = { ...project, x: cursorX + project.radius, y: 0 }
cursorX += project.radius * 2 + PROJECT_GAP
return positioned
})
}
export function placeAgentMapProjects<T extends ProjectCircle>(
projects: T[],
minimumWidth: number,
minimumHeight: number,
worldMargin: number
): { projects: T[]; width: number; height: number } {
const positioned = projects.some((project) => project.clusterParentId)
? layoutAgentMapWorktreeLineage(projects)
: placeUnlinkedProjects(projects)
const left = Math.min(...positioned.map((project) => project.x - project.radius))
const right = Math.max(...positioned.map((project) => project.x + project.radius))
const top = Math.min(...positioned.map((project) => project.y - project.radius))
const bottom = Math.max(...positioned.map((project) => project.y + project.radius))
const naturalWidth = right - left + worldMargin * 2
const naturalHeight = bottom - top + worldMargin * 2
const width = Math.max(minimumWidth, naturalWidth)
const height = Math.max(minimumHeight, naturalHeight)
const offsetX = worldMargin - left + (width - naturalWidth) / 2
const offsetY = worldMargin - top + (height - naturalHeight) / 2
return {
projects: positioned.map((project) => ({
...project,
x: project.x + offsetX,
y: project.y + offsetY
})),
width,
height
}
}
@@ -1,88 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { agentMapOrchestrationPaneKeys, filterAgentMapCards } from './agent-map-filter'
import { applyAgentMapQuickView, emptyAgentMapFilterState } from './agent-map-quick-views'
const NOW = 2_000_000_000
const MINUTE = 60_000
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
return {
paneKey: 'pane-1',
ptyId: null,
agentType: 'codex',
bucket: 'working',
dotState: 'working',
task: '',
repoId: 'repo-1',
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
repoName: 'Orca',
worktreeName: 'Agent map',
startedAt: NOW - 60 * MINUTE,
finishedAt: null,
stateChangedAt: NOW - 5 * MINUTE,
statusUpdatedAt: NOW - 5 * MINUTE,
unseen: false,
hostKind: 'local',
...overrides
}
}
const TYPES = ['claude', 'codex']
function visible(cards: DashboardCard[], view: Parameters<typeof applyAgentMapQuickView>[0]) {
const state = applyAgentMapQuickView(view, TYPES)
return filterAgentMapCards({
cards,
enabledStates: state.states,
enabledHosts: state.hosts,
enabledAgentTypes: state.agentTypes,
timeRanges: state.timeRanges,
orchestrationOnly: state.orchestrationOnly,
now: NOW
}).filter((c) => !state.unreadOnly || c.unseen)
}
describe('agent map quick views', () => {
it('replaces the filters rather than stacking on what was set', () => {
const stuck = applyAgentMapQuickView('stuck', TYPES)
const everything = applyAgentMapQuickView('everything', TYPES)
expect([...stuck.states]).toEqual(['working'])
expect([...everything.states].sort()).toEqual(['attention', 'done', 'idle', 'working'])
expect(everything.timeRanges).toEqual(emptyAgentMapFilterState(TYPES).timeRanges)
})
it('finds a working agent that has gone quiet, and ignores a chatty one', () => {
const quiet = card({ paneKey: 'quiet', statusUpdatedAt: NOW - 90 * MINUTE })
const chatty = card({ paneKey: 'chatty', statusUpdatedAt: NOW - MINUTE })
expect(visible([quiet, chatty], 'stuck').map((c) => c.paneKey)).toEqual(['quiet'])
})
it('keeps only unread agents under the unread view', () => {
const seen = card({ paneKey: 'seen', unseen: false })
const unseen = card({ paneKey: 'unseen', unseen: true })
expect(visible([seen, unseen], 'unread').map((c) => c.paneKey)).toEqual(['unseen'])
})
it('shows both ends of an orchestration flow, not just the dispatched child', () => {
const coordinator = card({ paneKey: 'coordinator' })
const child = card({ paneKey: 'child', parentPaneKey: 'coordinator' })
const unrelated = card({ paneKey: 'solo' })
expect(visible([coordinator, child, unrelated], 'orchestration').map((c) => c.paneKey)).toEqual(
['coordinator', 'child']
)
})
it('ignores a parent that is not on the map, so no half-flow is drawn', () => {
const orphan = card({ paneKey: 'orphan', parentPaneKey: 'coordinator-elsewhere' })
expect(agentMapOrchestrationPaneKeys([orphan]).size).toBe(0)
expect(visible([orphan], 'orchestration')).toEqual([])
})
})
@@ -1,131 +0,0 @@
import { translate } from '@/i18n/i18n'
import type { DashboardCardHostKind } from '../../../../shared/dashboard-snapshot'
import { ALL_AGENT_MAP_HOSTS, type AgentMapState } from './agent-map-filter'
import {
AGENT_MAP_TIME_MAX_INDEX,
fullAgentMapTimeRanges,
type AgentMapTimeRanges
} from './agent-map-time-filter'
export type AgentMapQuickViewId =
| 'everything'
| 'attention'
| 'stuck'
| 'unread'
| 'recent'
| 'longRunning'
| 'stale'
| 'orchestration'
export type AgentMapFilterState = {
states: ReadonlySet<AgentMapState>
hosts: ReadonlySet<DashboardCardHostKind>
agentTypes: ReadonlySet<string>
timeRanges: AgentMapTimeRanges
unreadOnly: boolean
orchestrationOnly: boolean
}
export const ALL_AGENT_MAP_STATES: readonly AgentMapState[] = [
'attention',
'working',
'done',
'idle'
]
/** Stop indices used by the quick views, named so the intent survives a re-scale. */
const STOP_30_MIN = 4
const STOP_1_DAY = 9
const STOP_3_DAY = 11
export function emptyAgentMapFilterState(agentTypes: readonly string[]): AgentMapFilterState {
return {
states: new Set(ALL_AGENT_MAP_STATES),
hosts: new Set(ALL_AGENT_MAP_HOSTS),
agentTypes: new Set(agentTypes),
timeRanges: fullAgentMapTimeRanges(),
unreadOnly: false,
orchestrationOnly: false
}
}
export const AGENT_MAP_QUICK_VIEWS: readonly {
id: AgentMapQuickViewId
label: () => string
apply: (base: AgentMapFilterState) => AgentMapFilterState
}[] = [
{
id: 'everything',
label: () => translate('dashboardPopout.map.quickView.everything', 'Everything'),
apply: (base) => base
},
{
id: 'attention',
label: () => translate('dashboardPopout.map.quickView.attention', 'Needs me'),
apply: (base) => ({ ...base, states: new Set<AgentMapState>(['attention', 'done']) })
},
{
id: 'stuck',
label: () => translate('dashboardPopout.map.quickView.stuck', 'Stuck'),
apply: (base) => ({
...base,
states: new Set<AgentMapState>(['working']),
timeRanges: {
...base.timeRanges,
sinceMessage: { min: STOP_30_MIN, max: AGENT_MAP_TIME_MAX_INDEX }
}
})
},
{
id: 'unread',
label: () => translate('dashboardPopout.map.quickView.unread', 'Unread'),
apply: (base) => ({ ...base, unreadOnly: true })
},
{
id: 'recent',
label: () => translate('dashboardPopout.map.quickView.recent', 'Last 30 min'),
apply: (base) => ({
...base,
timeRanges: { ...base.timeRanges, sinceMessage: { min: 0, max: STOP_30_MIN } }
})
},
{
id: 'longRunning',
label: () => translate('dashboardPopout.map.quickView.longRunning', 'Long runners'),
apply: (base) => ({
...base,
states: new Set<AgentMapState>(['attention', 'working']),
timeRanges: {
...base.timeRanges,
lifespan: { min: STOP_1_DAY, max: AGENT_MAP_TIME_MAX_INDEX }
}
})
},
{
id: 'stale',
label: () => translate('dashboardPopout.map.quickView.stale', 'Stale > 3d'),
apply: (base) => ({
...base,
timeRanges: {
...base.timeRanges,
sinceMessage: { min: STOP_3_DAY, max: AGENT_MAP_TIME_MAX_INDEX }
}
})
},
{
id: 'orchestration',
label: () => translate('dashboardPopout.map.quickView.orchestration', 'Orchestration'),
apply: (base) => ({ ...base, orchestrationOnly: true })
}
]
/** Quick views replace the filters wholesale; they are a starting point, not a
* toggle stacked on whatever was already set. */
export function applyAgentMapQuickView(
id: AgentMapQuickViewId,
agentTypes: readonly string[]
): AgentMapFilterState {
const view = AGENT_MAP_QUICK_VIEWS.find((candidate) => candidate.id === id)
const base = emptyAgentMapFilterState(agentTypes)
return view ? view.apply(base) : base
}
@@ -1,136 +0,0 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, vi } from 'vitest'
import type {
DashboardCard,
DashboardCardHostKind,
DashboardSleepWorkspaceArgs,
DashboardSpawnAgentArgs
} from '../../../../shared/dashboard-snapshot'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AgentMap } from './AgentMap'
import type { AgentMapState } from './agent-map-filter'
export const NOW = 2_000_000_000
export function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
return {
paneKey: 'pane-1',
ptyId: 'pty-1',
agentType: 'codex',
bucket: 'working',
dotState: 'working',
task: 'Build map',
repoId: 'repo-1',
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
repoName: 'Orca',
worktreeName: 'Agent map',
conversationName: 'Agent alpha',
startedAt: NOW - 10 * 60_000,
finishedAt: null,
stateChangedAt: NOW - 1_000,
unseen: false,
hostKind: 'local',
workspaceKind: 'worktree',
...overrides
}
}
export type RenderMapOptions = {
onOpenTerminal?: (card: DashboardCard) => void
selectedPaneKey?: string | null
workspaceContextMenusEnabled?: boolean
enabledStates?: ReadonlySet<AgentMapState>
enabledHosts?: ReadonlySet<DashboardCardHostKind>
showOrchestrationLinks?: boolean
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void
}
export function renderMap(
cards: DashboardCard[],
{
onOpenTerminal = vi.fn(),
selectedPaneKey = null,
workspaceContextMenusEnabled = false,
enabledStates,
enabledHosts,
showOrchestrationLinks,
launchableAgentsByWorktreeId,
onSpawnAgent,
onSleepWorkspace
}: RenderMapOptions = {}
): ReturnType<typeof render> {
return render(
<AgentMap
cards={cards}
now={NOW}
onOpenTerminal={onOpenTerminal}
selectedPaneKey={selectedPaneKey}
workspaceContextMenusEnabled={workspaceContextMenusEnabled}
enabledStates={enabledStates}
enabledHosts={enabledHosts}
showOrchestrationLinks={showOrchestrationLinks}
launchableAgentsByWorktreeId={launchableAgentsByWorktreeId}
onSpawnAgent={onSpawnAgent}
onSleepWorkspace={onSleepWorkspace}
/>,
{ wrapper: TooltipProvider }
)
}
export type AgentMapTestEnvironment = {
/** Exposed so tests can assert the canvas does not re-read layout when idle. */
boundsSpy: ReturnType<typeof vi.spyOn>
}
const CANVAS_BOUNDS = {
x: 0,
y: 0,
left: 0,
top: 0,
right: 400,
bottom: 300,
width: 400,
height: 300,
toJSON: () => ({})
}
const ZERO_BOUNDS = { ...CANVAS_BOUNDS, right: 0, bottom: 0, width: 0, height: 0 }
/** Gives the map a measurable canvas and a non-Mac platform, the way every map
* suite needs it. Call once per describe block. */
export function installAgentMapEnvironment(): AgentMapTestEnvironment {
const environment = {} as AgentMapTestEnvironment
const originalUserAgent = navigator.userAgent
beforeEach(() => {
Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Linux' })
vi.stubGlobal(
'matchMedia',
vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() }))
)
environment.boundsSpy = vi
.spyOn(Element.prototype, 'getBoundingClientRect')
.mockImplementation(function getBounds(this: Element) {
return this.classList.contains('agent-map-canvas') || this instanceof SVGSVGElement
? CANVAS_BOUNDS
: ZERO_BOUNDS
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
vi.unstubAllGlobals()
environment.boundsSpy.mockRestore()
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value: originalUserAgent
})
})
return environment
}
@@ -1,27 +0,0 @@
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
function compareStable(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0
}
export function selectAgentMapSpawnParentContainer(
cards: readonly DashboardCard[],
cardsByPaneKey: ReadonlyMap<string, DashboardCard>,
containerIdentity: (card: DashboardCard) => string
): string | undefined {
const ownContainerId = cards[0] ? containerIdentity(cards[0]) : undefined
const linkCounts = new Map<string, number>()
for (const card of cards) {
const parent = card.parentPaneKey ? cardsByPaneKey.get(card.parentPaneKey) : undefined
const parentContainerId = parent ? containerIdentity(parent) : undefined
if (!parentContainerId || parentContainerId === ownContainerId) {
continue
}
linkCounts.set(parentContainerId, (linkCounts.get(parentContainerId) ?? 0) + 1)
}
return [...linkCounts]
.sort(([leftId, leftCount], [rightId, rightCount]) =>
rightCount !== leftCount ? rightCount - leftCount : compareStable(leftId, rightId)
)
.at(0)?.[0]
}
@@ -1,95 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import {
AGENT_MAP_TIME_MAX_INDEX,
agentMapDurations,
agentMapTimeStopLabel,
fullAgentMapTimeRanges,
matchesAgentMapTimeRanges
} from './agent-map-time-filter'
const NOW = 2_000_000_000_000
const MINUTE = 60_000
const HOUR = 60 * MINUTE
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
return {
paneKey: 'pane-1',
ptyId: null,
agentType: 'codex',
bucket: 'working',
dotState: 'working',
task: '',
repoId: 'repo-1',
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
repoName: 'Orca',
worktreeName: 'Agent map',
startedAt: NOW - 2 * HOUR,
finishedAt: null,
stateChangedAt: NOW - 30 * MINUTE,
statusUpdatedAt: NOW - 10 * MINUTE,
unseen: false,
...overrides
}
}
describe('agent map time filtering', () => {
it('measures a finished agent to its finish, not to now', () => {
const finished = agentMapDurations(
card({ finishedAt: NOW - HOUR, startedAt: NOW - 3 * HOUR }),
NOW
)
const running = agentMapDurations(card({ startedAt: NOW - 3 * HOUR }), NOW)
expect(finished.lifespan).toBe(2 * HOUR)
expect(running.lifespan).toBe(3 * HOUR)
})
it('falls back to the state change when no hook update has landed', () => {
const durations = agentMapDurations(
card({ statusUpdatedAt: undefined, stateChangedAt: NOW - 45 * MINUTE }),
NOW
)
expect(durations.sinceMessage).toBe(45 * MINUTE)
expect(durations.timeInState).toBe(45 * MINUTE)
})
it('does not classify unknown timestamps as ancient', () => {
expect(
agentMapDurations(card({ startedAt: 0, stateChangedAt: 0, statusUpdatedAt: undefined }), NOW)
).toEqual({ lifespan: 0, sinceMessage: 0, timeInState: 0 })
})
it('keeps every card when the ranges are untouched', () => {
expect(matchesAgentMapTimeRanges(card(), fullAgentMapTimeRanges(), NOW)).toBe(true)
})
it('treats the top stop as unbounded so nothing falls off the end', () => {
const ancient = card({ startedAt: NOW - 400 * 24 * HOUR })
const ranges = fullAgentMapTimeRanges()
ranges.lifespan = { min: 9, max: AGENT_MAP_TIME_MAX_INDEX }
expect(matchesAgentMapTimeRanges(ancient, ranges, NOW)).toBe(true)
})
it('excludes a card quieter than the window and keeps one inside it', () => {
const ranges = fullAgentMapTimeRanges()
// Stop 4 is 30m: "stuck" means working with nothing said for half an hour.
ranges.sinceMessage = { min: 4, max: AGENT_MAP_TIME_MAX_INDEX }
expect(
matchesAgentMapTimeRanges(card({ statusUpdatedAt: NOW - 5 * MINUTE }), ranges, NOW)
).toBe(false)
expect(matchesAgentMapTimeRanges(card({ statusUpdatedAt: NOW - HOUR }), ranges, NOW)).toBe(true)
})
it('labels stops in the unit a human would say them in', () => {
expect(agentMapTimeStopLabel(0)).toBe('0')
expect(agentMapTimeStopLabel(4)).toBe('30m')
expect(agentMapTimeStopLabel(9)).toBe('1d')
expect(agentMapTimeStopLabel(AGENT_MAP_TIME_MAX_INDEX)).toBe('∞')
})
})
@@ -1,113 +0,0 @@
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
export type AgentMapTimeField = 'lifespan' | 'sinceMessage' | 'timeInState'
/** Inclusive stop indices into `AGENT_MAP_TIME_STOPS`. */
export type AgentMapTimeRange = { min: number; max: number }
export type AgentMapTimeRanges = Record<AgentMapTimeField, AgentMapTimeRange>
const MINUTE = 60_000
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
/** Non-linear stops: minutes matter as much as days, so a linear axis would
* bury every useful threshold in the first pixel. */
export const AGENT_MAP_TIME_STOPS: readonly number[] = [
0,
MINUTE,
5 * MINUTE,
15 * MINUTE,
30 * MINUTE,
HOUR,
3 * HOUR,
6 * HOUR,
12 * HOUR,
DAY,
2 * DAY,
3 * DAY,
7 * DAY,
14 * DAY,
Number.POSITIVE_INFINITY
]
export const AGENT_MAP_TIME_MAX_INDEX = AGENT_MAP_TIME_STOPS.length - 1
export const AGENT_MAP_TIME_FIELDS: readonly AgentMapTimeField[] = [
'lifespan',
'sinceMessage',
'timeInState'
]
export const FULL_AGENT_MAP_TIME_RANGE: AgentMapTimeRange = {
min: 0,
max: AGENT_MAP_TIME_MAX_INDEX
}
export function fullAgentMapTimeRanges(): AgentMapTimeRanges {
return {
lifespan: { ...FULL_AGENT_MAP_TIME_RANGE },
sinceMessage: { ...FULL_AGENT_MAP_TIME_RANGE },
timeInState: { ...FULL_AGENT_MAP_TIME_RANGE }
}
}
export function isFullAgentMapTimeRange(range: AgentMapTimeRange): boolean {
return range.min <= 0 && range.max >= AGENT_MAP_TIME_MAX_INDEX
}
export function agentMapTimeStopLabel(index: number): string {
const ms = AGENT_MAP_TIME_STOPS[Math.min(Math.max(index, 0), AGENT_MAP_TIME_MAX_INDEX)]
if (!Number.isFinite(ms)) {
return '∞'
}
if (ms === 0) {
return '0'
}
if (ms < HOUR) {
return `${Math.round(ms / MINUTE)}m`
}
if (ms < DAY) {
return `${Math.round(ms / HOUR)}h`
}
return `${Math.round(ms / DAY)}d`
}
/** How long the agent has been alive, quiet, and sitting in its current state. */
export function agentMapDurations(
card: DashboardCard,
now: number
): Record<AgentMapTimeField, number> {
const startedAt = validTimestamp(card.startedAt) ? card.startedAt : null
const enteredState = validTimestamp(card.stateChangedAt) ? card.stateChangedAt : startedAt
const lastMessage = validTimestamp(card.statusUpdatedAt) ? card.statusUpdatedAt : enteredState
const finishedAt = validTimestamp(card.finishedAt) ? card.finishedAt : null
return {
lifespan: startedAt === null ? 0 : Math.max(0, (finishedAt ?? now) - startedAt),
// No per-message timestamp rides the snapshot; the last accepted hook update
// is the closest thing to "when this agent last said something".
sinceMessage: lastMessage === null ? 0 : Math.max(0, now - lastMessage),
timeInState: enteredState === null ? 0 : Math.max(0, now - enteredState)
}
}
function validTimestamp(value: number | null | undefined): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0
}
function withinRange(value: number, range: AgentMapTimeRange): boolean {
if (value < AGENT_MAP_TIME_STOPS[Math.max(0, range.min)]) {
return false
}
return range.max >= AGENT_MAP_TIME_MAX_INDEX || value <= AGENT_MAP_TIME_STOPS[range.max]
}
export function matchesAgentMapTimeRanges(
card: DashboardCard,
ranges: AgentMapTimeRanges,
now: number
): boolean {
const durations = agentMapDurations(card, now)
return AGENT_MAP_TIME_FIELDS.every((field) => withinRange(durations[field], ranges[field]))
}
export function activeAgentMapTimeFields(ranges: AgentMapTimeRanges): AgentMapTimeField[] {
return AGENT_MAP_TIME_FIELDS.filter((field) => !isFullAgentMapTimeRange(ranges[field]))
}
@@ -1,56 +0,0 @@
export type AgentMapViewport = {
center: { x: number; y: number }
zoom: number
}
type ViewportTransitionOptions = {
from: AgentMapViewport
to: AgentMapViewport
durationMs: number
onFrame: (viewport: AgentMapViewport) => void
onComplete?: () => void
}
function interpolate(from: number, to: number, progress: number): number {
return from + (to - from) * progress
}
export function startAgentMapViewportTransition({
from,
to,
durationMs,
onFrame,
onComplete
}: ViewportTransitionOptions): () => void {
let frameId: number | null = null
let startedAt: number | null = null
let cancelled = false
const tick = (now: number): void => {
if (cancelled) {
return
}
startedAt ??= now
const progress = Math.min(1, (now - startedAt) / durationMs)
const eased = 1 - (1 - progress) ** 3
onFrame({
center: {
x: interpolate(from.center.x, to.center.x, eased),
y: interpolate(from.center.y, to.center.y, eased)
},
zoom: interpolate(from.zoom, to.zoom, eased)
})
if (progress < 1) {
frameId = requestAnimationFrame(tick)
} else {
frameId = null
onComplete?.()
}
}
frameId = requestAnimationFrame(tick)
return () => {
cancelled = true
if (frameId !== null) {
cancelAnimationFrame(frameId)
}
}
}
@@ -1,29 +0,0 @@
import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot'
export function agentMapCardTopologyIdentity(card: DashboardCard): string {
const parentPaneKey = card.parentPaneKey ?? ''
const parentWorktreeId = card.parentWorktreeId ?? ''
const executionHostId = card.executionHostId ?? ''
return `${card.repoId.length}:${card.repoId}${card.worktreeId.length}:${card.worktreeId}${executionHostId.length}:${executionHostId}${card.paneKey.length}:${card.paneKey}${parentPaneKey.length}:${parentPaneKey}${parentWorktreeId.length}:${parentWorktreeId}`
}
export function agentMapWorkspaceTopologyIdentity(workspace: DashboardWorkspace): string {
const parentWorktreeId = workspace.parentWorktreeId ?? ''
return `${workspace.repoId.length}:${workspace.repoId}${workspace.worktreeId.length}:${workspace.worktreeId}${workspace.executionHostId.length}:${workspace.executionHostId}${parentWorktreeId.length}:${parentWorktreeId}`
}
export function agentMapWorktreeIdentityFromParts(
worktreeId: string,
executionHostId: DashboardCard['executionHostId']
): string {
const hostId = executionHostId ?? ''
return `${worktreeId.length}:${worktreeId}${hostId.length}:${hostId}`
}
export function agentMapWorktreeIdentity(card: DashboardCard): string {
return agentMapWorktreeIdentityFromParts(card.worktreeId, card.executionHostId)
}
export function agentMapWorkspaceIdentity(workspace: DashboardWorkspace): string {
return agentMapWorktreeIdentityFromParts(workspace.worktreeId, workspace.executionHostId)
}
@@ -1,81 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot'
import { EMPTY_DASHBOARD_FILTERS } from './agent-board-filtering'
import { selectAgentlessMapWorkspaces } from './agent-map-workspace-visibility'
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
return {
paneKey: 'pane-1',
ptyId: null,
agentType: 'codex',
bucket: 'working',
dotState: 'working',
task: '',
repoId: 'repo-1',
worktreeId: 'occupied',
tabId: 'tab-1',
leafId: null,
repoName: 'Orca',
worktreeName: 'Occupied',
executionHostId: 'local',
startedAt: 0,
finishedAt: null,
stateChangedAt: 0,
unseen: false,
...overrides
}
}
function workspace(overrides: Partial<DashboardWorkspace> = {}): DashboardWorkspace {
return {
repoId: 'repo-1',
worktreeId: 'empty',
repoName: 'Orca',
worktreeName: 'Empty child',
hostKind: 'local',
executionHostId: 'local',
workspaceKind: 'worktree',
workspaceStatusId: 'planned',
...overrides
}
}
describe('agent map workspace visibility', () => {
it('returns only workspaces that have no dashboard card on the same host', () => {
const result = selectAgentlessMapWorkspaces({
cards: [card()],
workspaces: [
workspace({ worktreeId: 'occupied', worktreeName: 'Occupied' }),
workspace(),
workspace({
worktreeId: 'occupied',
worktreeName: 'Remote twin',
hostKind: 'ssh',
executionHostId: 'ssh:build-box'
})
],
query: '',
filters: EMPTY_DASHBOARD_FILTERS
})
expect(result.map((item) => item.worktreeName)).toEqual(['Empty child', 'Remote twin'])
})
it('applies search and workspace filters to agentless workspaces', () => {
const result = selectAgentlessMapWorkspaces({
cards: [],
workspaces: [
workspace({ worktreeName: 'Listener security', review: { number: 42, state: 'open' } }),
workspace({ worktreeId: 'other', worktreeName: 'Unrelated', workspaceStatusId: 'active' })
],
query: 'listener',
filters: {
projects: ['repo-1'],
workspaceStatuses: ['planned'],
reviewStates: ['open']
}
})
expect(result.map((item) => item.worktreeName)).toEqual(['Listener security'])
})
})
@@ -1,25 +0,0 @@
import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot'
import { filterDashboardWorkspaces, type DashboardFilters } from './agent-board-filtering'
import { agentMapWorktreeIdentityFromParts } from './agent-map-workspace-identity'
export function selectAgentlessMapWorkspaces({
cards,
workspaces,
query,
filters
}: {
cards: DashboardCard[]
workspaces: DashboardWorkspace[]
query: string
filters: DashboardFilters
}): DashboardWorkspace[] {
const occupiedWorkspaceIds = new Set(
cards.map((card) => agentMapWorktreeIdentityFromParts(card.worktreeId, card.executionHostId))
)
return filterDashboardWorkspaces(workspaces, query, filters).filter(
(workspace) =>
!occupiedWorkspaceIds.has(
agentMapWorktreeIdentityFromParts(workspace.worktreeId, workspace.executionHostId)
)
)
}
@@ -1,43 +0,0 @@
import { describe, expect, it } from 'vitest'
import { emptyAgentMapStatusCounts, type AgentMapStatusCounts } from './agent-map-node-metadata'
import { agentMapWorktreeActiveStatus } from './agent-map-worktree-active-status'
function counts(overrides: Partial<AgentMapStatusCounts> = {}): AgentMapStatusCounts {
return { ...emptyAgentMapStatusCounts(), ...overrides }
}
describe('agentMapWorktreeActiveStatus', () => {
it('turns the ring green only once the whole workspace has settled', () => {
expect(agentMapWorktreeActiveStatus(counts({ done: 1 }))).toBe('done')
// Anything still running outranks a finished sibling — the workspace is still working.
expect(agentMapWorktreeActiveStatus(counts({ done: 1, working: 1 }))).toBe('working')
expect(agentMapWorktreeActiveStatus(counts({ done: 1, waiting: 1 }))).toBe('waiting')
expect(agentMapWorktreeActiveStatus(counts({ done: 1, blocked: 1 }))).toBe('blocked')
})
it('leaves the ring unlit for acknowledged finishes and idle workspaces', () => {
// Acknowledging is what releases the attention, exactly as at the node level.
expect(agentMapWorktreeActiveStatus(counts({ 'done-seen': 3 }))).toBeNull()
expect(agentMapWorktreeActiveStatus(counts({ idle: 2 }))).toBeNull()
expect(agentMapWorktreeActiveStatus(counts())).toBeNull()
})
it('prioritizes attention over working', () => {
expect(agentMapWorktreeActiveStatus(counts({ working: 2, waiting: 1 }))).toBe('waiting')
expect(agentMapWorktreeActiveStatus(counts({ working: 2, waiting: 1, blocked: 1 }))).toBe(
'blocked'
)
})
it('uses working only when no agent needs attention', () => {
expect(agentMapWorktreeActiveStatus(counts({ working: 1, done: 2 }))).toBe('working')
// Was null before unread finishes lit the ring; idle siblings do not mute a finish.
expect(agentMapWorktreeActiveStatus(counts({ done: 2, idle: 1 }))).toBe('done')
expect(agentMapWorktreeActiveStatus(counts({ 'done-seen': 2, idle: 1 }))).toBeNull()
})
it('keeps passive monitoring out of the active-worktree glow', () => {
expect(agentMapWorktreeActiveStatus(counts({ monitoring: 1 }))).toBeNull()
expect(agentMapWorktreeActiveStatus(counts({ working: 1, monitoring: 1 }))).toBe('working')
})
})
@@ -1,25 +0,0 @@
import type { AgentMapStatusCounts } from './agent-map-node-metadata'
export type AgentMapWorktreeActiveStatus = 'blocked' | 'waiting' | 'working' | 'done'
/**
* Most urgent first. `done` ranks last on purpose: a workspace with anything still
* running is a working workspace, even if a sibling agent already finished — the ring
* only turns green once the whole workspace has settled and a finish is still unread.
* `done-seen` never lights the ring, matching the node treatment where acknowledging a
* finish is what releases the attention.
*/
export function agentMapWorktreeActiveStatus(
counts: AgentMapStatusCounts
): AgentMapWorktreeActiveStatus | null {
if (counts.blocked > 0) {
return 'blocked'
}
if (counts.waiting > 0) {
return 'waiting'
}
if (counts.working > 0) {
return 'working'
}
return counts.done > 0 ? 'done' : null
}
@@ -1,25 +0,0 @@
import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot'
import { parseExecutionHostId } from '../../../../shared/execution-host'
export function agentMapWorktreeHost(
cards: DashboardCard[],
workspace?: DashboardWorkspace
): {
executionHostId: DashboardCard['executionHostId']
hostKind: DashboardCard['hostKind']
hostLabel: DashboardCard['hostLabel']
} {
const executionHostId = workspace?.executionHostId ?? cards[0]?.executionHostId
const parsedHost = parseExecutionHostId(executionHostId)
const hostKind =
parsedHost?.kind === 'ssh'
? 'ssh'
: parsedHost?.kind === 'runtime'
? 'remote'
: (workspace?.hostKind ?? cards[0]?.hostKind)
return {
executionHostId,
hostKind,
hostLabel: workspace?.hostLabel ?? cards[0]?.hostLabel
}
}
@@ -1,223 +0,0 @@
import { describe, expect, it } from 'vitest'
import { AGENT_MAP_WORKTREE_GAP } from './agent-map-worktree-packing'
import { layoutAgentMapWorktreeLineage } from './agent-map-worktree-lineage-layout'
function buildChain(count: number) {
return Array.from({ length: count }, (_, index) => ({
id: `worktree-${index.toString().padStart(4, '0')}`,
parentId: index === 0 ? undefined : `worktree-${(index - 1).toString().padStart(4, '0')}`,
radius: 32,
x: 0,
y: 0
}))
}
function buildComb(spineCount: number) {
const worktrees: ReturnType<typeof buildChain> = []
for (let index = 0; index < spineCount; index += 1) {
const suffix = index.toString().padStart(4, '0')
worktrees.push({
id: `spine-${suffix}`,
parentId: index === 0 ? undefined : `spine-${(index - 1).toString().padStart(4, '0')}`,
radius: 32,
x: 0,
y: 0
})
if (index < spineCount - 1) {
worktrees.push({
id: `leaf-${suffix}`,
parentId: `spine-${suffix}`,
radius: 24,
x: 0,
y: 0
})
}
}
return worktrees
}
function layoutWithNumericMapSetCount(worktrees: ReturnType<typeof buildChain>) {
const set = Map.prototype.set
let numericMapSets = 0
Map.prototype.set = function (this: Map<unknown, unknown>, key: unknown, value: unknown) {
if (typeof key === 'number') {
numericMapSets += 1
}
return set.call(this, key, value)
}
try {
return { layout: layoutAgentMapWorktreeLineage(worktrees), numericMapSets }
} finally {
Map.prototype.set = set
}
}
function layoutWithWorktreePushCount(count: number) {
const push = Array.prototype.push
let worktreePushes = 0
Array.prototype.push = function (...items: unknown[]): number {
worktreePushes += items.filter(
(item) =>
typeof item === 'object' &&
item !== null &&
'id' in item &&
typeof item.id === 'string' &&
item.id.startsWith('worktree-')
).length
return push.call(this, ...items)
}
try {
return {
layout: layoutAgentMapWorktreeLineage(buildChain(count)),
worktreePushes
}
} finally {
Array.prototype.push = push
}
}
describe('layoutAgentMapWorktreeLineage', () => {
it('keeps branched and linear family coordinates deterministic', () => {
const layout = layoutAgentMapWorktreeLineage([
{ id: 'root', x: 0, y: 0, radius: 40 },
{ id: 'child-a', parentId: 'root', x: 0, y: 0, radius: 30 },
{ id: 'grandchild-a', parentId: 'child-a', x: 0, y: 0, radius: 25 },
{ id: 'child-b', parentId: 'root', x: 0, y: 0, radius: 45 },
{ id: 'second-root', x: 0, y: 0, radius: 35 },
{ id: 'second-child', parentId: 'second-root', x: 0, y: 0, radius: 20 }
])
expect(layout).toEqual([
{
id: 'child-a',
parentId: 'root',
radius: 30,
x: -7.740689238053122,
y: 42.47172405948656
},
{
id: 'child-b',
parentId: 'root',
radius: 45,
x: 69.93546272327787,
y: 175.54837055839306
},
{
id: 'grandchild-a',
parentId: 'child-a',
radius: 25,
x: -7.740689238053122,
y: 125.47172405948656
},
{ id: 'root', radius: 40, x: 19.097386742612372, y: -55.52827594051344 },
{
id: 'second-child',
parentId: 'second-root',
radius: 20,
x: -112.9872940755618,
y: -73.65876088400047
},
{
id: 'second-root',
radius: 35,
x: -112.9872940755618,
y: -156.65876088400046
}
])
})
it('flattens a 1,000-worktree lineage once', () => {
const { layout, worktreePushes } = layoutWithWorktreePushCount(1_000)
expect(layout).toHaveLength(1_000)
expect(worktreePushes).toBeLessThan(5_000)
for (let index = 1; index < layout.length; index += 1) {
expect(layout[index].y).toBeGreaterThan(layout[index - 1].y)
expect(layout[index].y - layout[index - 1].y).toBeGreaterThanOrEqual(
layout[index].radius + layout[index - 1].radius + AGENT_MAP_WORKTREE_GAP
)
}
})
it.each([
[399, 200],
[999, 500]
])('avoids spatial-grid expansion for a %i-worktree comb', (expectedCount, spineCount) => {
const worktrees = buildComb(spineCount)
const { layout, numericMapSets } = layoutWithNumericMapSetCount(worktrees)
expect(layout).toHaveLength(expectedCount)
expect(numericMapSets).toBeLessThan(10)
expect(layoutAgentMapWorktreeLineage(worktrees)).toEqual(layout)
})
it('keeps a deeply branched lineage finite without recursive stack growth', () => {
const worktrees = buildComb(2_500)
const layout = layoutAgentMapWorktreeLineage(worktrees)
const byId = new Map(layout.map((worktree) => [worktree.id, worktree]))
expect(layout).toHaveLength(4_999)
expect(
layout.every(
(worktree) =>
Number.isFinite(worktree.x) &&
Number.isFinite(worktree.y) &&
Math.abs(worktree.x) < 1_000_000 &&
Math.abs(worktree.y) < 1_000_000
)
).toBe(true)
for (const worktree of layout) {
if (worktree.parentId) {
expect(worktree.y).toBeGreaterThan(byId.get(worktree.parentId)!.y)
}
}
})
it('wraps very large worktree fanout without overlap', () => {
const layout = layoutAgentMapWorktreeLineage([
{ id: 'parent', x: 0, y: 0, radius: 32 },
...Array.from({ length: 300 }, (_, index) => ({
id: `child-${index}`,
parentId: 'parent',
x: 0,
y: 0,
radius: 24
}))
])
const parent = layout.find((worktree) => worktree.id === 'parent')!
const children = layout.filter(
(worktree) => 'parentId' in worktree && worktree.parentId === 'parent'
)
let minimumGap = Number.POSITIVE_INFINITY
expect(children.every((child) => child.y > parent.y)).toBe(true)
for (const [index, child] of children.entries()) {
for (const other of children.slice(index + 1)) {
minimumGap = Math.min(
minimumGap,
Math.hypot(child.x - other.x, child.y - other.y) - child.radius - other.radius
)
}
}
expect(minimumGap).toBeGreaterThanOrEqual(AGENT_MAP_WORKTREE_GAP)
})
it('packs high-fanout spawn clusters without forcing every workspace below the coordinator', () => {
const layout = layoutAgentMapWorktreeLineage([
{ id: 'parent', x: 0, y: 0, radius: 32 },
...Array.from({ length: 13 }, (_, index) => ({
id: `child-${index.toString().padStart(2, '0')}`,
clusterParentId: 'parent',
x: 0,
y: 0,
radius: 24
}))
])
const parent = layout.find((worktree) => worktree.id === 'parent')!
const children = layout.filter(
(worktree) => 'clusterParentId' in worktree && worktree.clusterParentId === 'parent'
)
expect(children.some((child) => child.y <= parent.y)).toBe(true)
})
})
@@ -1,279 +0,0 @@
import { AGENT_MAP_WORKTREE_GAP, packAgentMapWorktrees } from './agent-map-worktree-packing'
const LINEAGE_VERTICAL_GAP = 28
const MAX_HIERARCHICAL_CLUSTER_FANOUT = 12
const MAX_EXACT_LINEAGE_WORKTREES = 256
type LineageWorktree = {
id: string
parentId?: string
clusterParentId?: string
x: number
y: number
radius: number
}
type WorktreeFamily<T extends LineageWorktree> = {
id: string
x: number
y: number
radius: number
worktrees: T[]
}
function compareStable(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0
}
function encloseFamily<T extends LineageWorktree>(id: string, worktrees: T[]): WorktreeFamily<T> {
const left = Math.min(...worktrees.map((worktree) => worktree.x - worktree.radius))
const right = Math.max(...worktrees.map((worktree) => worktree.x + worktree.radius))
const top = Math.min(...worktrees.map((worktree) => worktree.y - worktree.radius))
const bottom = Math.max(...worktrees.map((worktree) => worktree.y + worktree.radius))
const centerX = (left + right) / 2
const centerY = (top + bottom) / 2
for (const worktree of worktrees) {
worktree.x -= centerX
worktree.y -= centerY
}
return {
id,
x: 0,
y: 0,
radius: Math.max(
...worktrees.map((worktree) => Math.hypot(worktree.x, worktree.y) + worktree.radius)
),
worktrees
}
}
function buildFamily<T extends LineageWorktree>(
root: T,
childrenByParent: ReadonlyMap<string, T[]>,
emitted: Set<string>,
ancestors: ReadonlySet<string>
): WorktreeFamily<T> {
emitted.add(root.id)
const nextAncestors = new Set(ancestors)
nextAncestors.add(root.id)
const children = (childrenByParent.get(root.id) ?? []).filter(
(child) => !nextAncestors.has(child.id) && !emitted.has(child.id)
)
if (children.length === 0) {
return {
id: root.id,
x: 0,
y: 0,
radius: root.radius,
worktrees: [{ ...root, x: 0, y: 0 }]
}
}
const childFamilies = packAgentMapWorktrees(
children.map((child) => buildExactFamily(child, childrenByParent, emitted))
)
const childLeft = Math.min(...childFamilies.map((family) => family.x - family.radius))
const childRight = Math.max(...childFamilies.map((family) => family.x + family.radius))
const childTop = Math.min(...childFamilies.map((family) => family.y - family.radius))
const childOffsetX = -(childLeft + childRight) / 2
const childOffsetY = root.radius + LINEAGE_VERTICAL_GAP - childTop
const worktrees = [{ ...root, x: 0, y: 0 }]
for (const family of childFamilies) {
for (const worktree of family.worktrees) {
worktrees.push({
...worktree,
x: worktree.x + family.x + childOffsetX,
y: worktree.y + family.y + childOffsetY
})
}
}
return encloseFamily(root.id, worktrees)
}
function collectLinearFamily<T extends LineageWorktree>(
root: T,
childrenByParent: ReadonlyMap<string, T[]>,
emitted: ReadonlySet<string>
): T[] | null {
const worktrees: T[] = []
const ancestors = new Set<string>()
let current: T | undefined = root
while (current) {
worktrees.push(current)
ancestors.add(current.id)
const children = (childrenByParent.get(current.id) ?? []).filter(
(child) => !ancestors.has(child.id) && !emitted.has(child.id)
)
if (children.length > 1) {
return null
}
current = children[0]
}
return worktrees
}
function buildLinearFamily<T extends LineageWorktree>(worktrees: T[]): WorktreeFamily<T> {
const positioned = worktrees.map((worktree) => ({ ...worktree, x: 0, y: 0 }))
let radius = worktrees.at(-1)?.radius ?? 0
for (let index = worktrees.length - 2; index >= 0; index -= 1) {
positioned[index].y = -(radius + LINEAGE_VERTICAL_GAP / 2)
radius += worktrees[index].radius + LINEAGE_VERTICAL_GAP / 2
}
let familyCenterY = 0
for (let index = 0; index < positioned.length; index += 1) {
positioned[index].y += familyCenterY
familyCenterY += worktrees[index].radius + LINEAGE_VERTICAL_GAP / 2
}
return { id: worktrees[0].id, x: 0, y: 0, radius, worktrees: positioned }
}
function buildExactFamily<T extends LineageWorktree>(
root: T,
childrenByParent: ReadonlyMap<string, T[]>,
emitted: Set<string>
): WorktreeFamily<T> {
const linearFamily = collectLinearFamily(root, childrenByParent, emitted)
if (!linearFamily) {
return buildFamily(root, childrenByParent, emitted, new Set())
}
for (const worktree of linearFamily) {
emitted.add(worktree.id)
}
return buildLinearFamily(linearFamily)
}
function layoutBoundedLineage<T extends LineageWorktree>(
sorted: T[],
childrenByParent: ReadonlyMap<string, T[]>,
childIds: ReadonlySet<string>
): T[] {
const levels: T[][] = []
const emitted = new Set<string>()
const roots = sorted.filter((worktree) => !childIds.has(worktree.id))
for (const seed of [...roots, ...sorted]) {
if (emitted.has(seed.id)) {
continue
}
const stack = [{ depth: 0, worktree: seed }]
while (stack.length > 0) {
const entry = stack.pop()!
if (emitted.has(entry.worktree.id)) {
continue
}
emitted.add(entry.worktree.id)
const level = levels[entry.depth] ?? []
levels[entry.depth] = level
level.push(entry.worktree)
const children = childrenByParent.get(entry.worktree.id) ?? []
for (let index = children.length - 1; index >= 0; index -= 1) {
if (!emitted.has(children[index].id)) {
stack.push({ depth: entry.depth + 1, worktree: children[index] })
}
}
}
}
const positioned: T[] = []
let y = 0
let previousMaxRadius = 0
let hasPositionedRow = false
for (const level of levels) {
const columns = Math.ceil(Math.sqrt(level.length))
for (let rowStart = 0; rowStart < level.length; rowStart += columns) {
const row = level.slice(rowStart, rowStart + columns)
let maxRadius = 0
let width = -AGENT_MAP_WORKTREE_GAP
for (const worktree of row) {
maxRadius = Math.max(maxRadius, worktree.radius)
width += worktree.radius * 2 + AGENT_MAP_WORKTREE_GAP
}
if (hasPositionedRow) {
y += previousMaxRadius + maxRadius + LINEAGE_VERTICAL_GAP
}
let x = -width / 2
for (const worktree of row) {
positioned.push({ ...worktree, x: x + worktree.radius, y })
x += worktree.radius * 2 + AGENT_MAP_WORKTREE_GAP
}
previousMaxRadius = maxRadius
hasPositionedRow = true
}
}
let left = Number.POSITIVE_INFINITY
let right = Number.NEGATIVE_INFINITY
let top = Number.POSITIVE_INFINITY
let bottom = Number.NEGATIVE_INFINITY
for (const worktree of positioned) {
left = Math.min(left, worktree.x - worktree.radius)
right = Math.max(right, worktree.x + worktree.radius)
top = Math.min(top, worktree.y - worktree.radius)
bottom = Math.max(bottom, worktree.y + worktree.radius)
}
const centerX = (left + right) / 2
const centerY = (top + bottom) / 2
return positioned
.map((worktree) => ({ ...worktree, x: worktree.x - centerX, y: worktree.y - centerY }))
.sort((a, b) => compareStable(a.id, b.id))
}
export function layoutAgentMapWorktreeLineage<T extends LineageWorktree>(worktrees: T[]): T[] {
const sorted = [...worktrees].sort((a, b) => compareStable(a.id, b.id))
const worktreesById = new Map(sorted.map((worktree) => [worktree.id, worktree]))
const clusterChildCounts = new Map<string, number>()
for (const worktree of sorted) {
if (worktree.clusterParentId && worktreesById.has(worktree.clusterParentId)) {
clusterChildCounts.set(
worktree.clusterParentId,
(clusterChildCounts.get(worktree.clusterParentId) ?? 0) + 1
)
}
}
const childrenByParent = new Map<string, T[]>()
const childIds = new Set<string>()
for (const worktree of sorted) {
const clusterParentId = worktree.clusterParentId
const parentId =
clusterParentId &&
(clusterChildCounts.get(clusterParentId) ?? 0) <= MAX_HIERARCHICAL_CLUSTER_FANOUT
? clusterParentId
: worktree.parentId
if (!parentId || parentId === worktree.id || !worktreesById.has(parentId)) {
continue
}
childIds.add(worktree.id)
const siblings = childrenByParent.get(parentId)
if (siblings) {
siblings.push(worktree)
} else {
childrenByParent.set(parentId, [worktree])
}
}
if (sorted.length > MAX_EXACT_LINEAGE_WORKTREES) {
return layoutBoundedLineage(sorted, childrenByParent, childIds)
}
const emitted = new Set<string>()
const families: WorktreeFamily<T>[] = []
for (const root of sorted.filter((worktree) => !childIds.has(worktree.id))) {
if (!emitted.has(root.id)) {
families.push(buildExactFamily(root, childrenByParent, emitted))
}
}
for (const worktree of sorted) {
if (!emitted.has(worktree.id)) {
families.push(buildExactFamily(worktree, childrenByParent, emitted))
}
}
return packAgentMapWorktrees(families)
.flatMap((family) =>
family.worktrees.map((worktree) => ({
...worktree,
x: worktree.x + family.x,
y: worktree.y + family.y
}))
)
.sort((a, b) => compareStable(a.id, b.id))
}
@@ -1,168 +0,0 @@
import { describe, expect, it } from 'vitest'
import { AGENT_MAP_WORKTREE_GAP, packAgentMapWorktrees } from './agent-map-worktree-packing'
function circles(count = 80): { id: string; x: number; y: number; radius: number }[] {
return Array.from({ length: count }, (_, index) => ({
id: `worktree-${index.toString().padStart(2, '0')}`,
x: 0,
y: 0,
radius: 28 + (index % 7) * 13
}))
}
function measuredCircles(count = 80): {
worktrees: ReturnType<typeof circles>
coordinateReads: () => number
} {
let reads = 0
const worktrees = circles(count).map(({ id, radius }) => {
let x = 0
let y = 0
return {
id,
radius,
get x() {
reads += 1
return x
},
set x(value: number) {
x = value
},
get y() {
reads += 1
return y
},
set y(value: number) {
y = value
}
}
})
return { worktrees, coordinateReads: () => reads }
}
describe('packAgentMapWorktrees', () => {
it('keeps variable-radius rings deterministic and non-overlapping', () => {
const first = packAgentMapWorktrees(circles())
const second = packAgentMapWorktrees(circles())
expect(second).toEqual(first)
for (const [index, worktree] of first.entries()) {
for (const other of first.slice(index + 1)) {
expect(Math.hypot(worktree.x - other.x, worktree.y - other.y)).toBeGreaterThanOrEqual(
worktree.radius + other.radius + AGENT_MAP_WORKTREE_GAP - 0.001
)
}
}
})
it('indexes rings that span multiple positive and negative grid cells', () => {
const packed = packAgentMapWorktrees(
[380, 260, 170, 145, 90].map((radius, index) => ({
id: `large-${index}`,
x: 0,
y: 0,
radius
}))
)
expect(packed.some((worktree) => worktree.x < 0 || worktree.y < 0)).toBe(true)
for (const [index, worktree] of packed.entries()) {
for (const other of packed.slice(index + 1)) {
expect(Math.hypot(worktree.x - other.x, worktree.y - other.y)).toBeGreaterThanOrEqual(
worktree.radius + other.radius + AGENT_MAP_WORKTREE_GAP - 0.001
)
}
}
})
it('keeps capped large-map packing deterministic and compact', () => {
const first = packAgentMapWorktrees(circles(300))
const second = packAgentMapWorktrees(circles(300))
expect(second).toEqual(first)
expect(
Math.max(...first.map((worktree) => Math.hypot(worktree.x, worktree.y) + worktree.radius))
).toBeLessThan(1_500)
let minimumGap = Number.POSITIVE_INFINITY
for (const [index, worktree] of first.entries()) {
for (const other of first.slice(index + 1)) {
minimumGap = Math.min(
minimumGap,
Math.hypot(worktree.x - other.x, worktree.y - other.y) - worktree.radius - other.radius
)
}
}
expect(minimumGap).toBeGreaterThanOrEqual(AGENT_MAP_WORKTREE_GAP - 0.001)
})
it('bounds deterministic coordinate checks for larger maps', () => {
const { worktrees, coordinateReads } = measuredCircles(300)
packAgentMapWorktrees(worktrees)
expect(coordinateReads()).toBeLessThan(13_200_000)
})
it('bounds packing work for a thousand rings', () => {
const { worktrees, coordinateReads } = measuredCircles(1_000)
const packed = packAgentMapWorktrees(worktrees)
const positions = packed.map(({ id, x, y, radius }) => ({ id, x, y, radius }))
expect(packed).toHaveLength(1_000)
expect(
packed.every((worktree) => Number.isFinite(worktree.x) && Number.isFinite(worktree.y))
).toBe(true)
expect(coordinateReads()).toBeLessThan(10_000_000)
expect(packAgentMapWorktrees(circles(1_000))).toEqual(positions)
let minimumGap = Number.POSITIVE_INFINITY
for (const [index, worktree] of positions.entries()) {
for (const other of positions.slice(index + 1)) {
minimumGap = Math.min(
minimumGap,
Math.hypot(worktree.x - other.x, worktree.y - other.y) - worktree.radius - other.radius
)
}
}
expect(minimumGap).toBeGreaterThanOrEqual(AGENT_MAP_WORKTREE_GAP - 0.001)
})
it('bounds packing work when one ring dwarfs the rest', () => {
const { worktrees, coordinateReads } = measuredCircles(1_000)
worktrees[0].radius = 50_000_000
const packed = packAgentMapWorktrees(worktrees)
expect(packed).toHaveLength(1_000)
expect(
packed.every((worktree) => Number.isFinite(worktree.x) && Number.isFinite(worktree.y))
).toBe(true)
expect(coordinateReads()).toBeLessThan(10_000_000)
})
it('keeps the spatial index bounded for very large rings', () => {
const set = Map.prototype.set
let numericMapSets = 0
Map.prototype.set = function (this: Map<unknown, unknown>, key: unknown, value: unknown) {
if (typeof key === 'number') {
numericMapSets += 1
}
return set.call(this, key, value)
}
try {
const packed = packAgentMapWorktrees(
Array.from({ length: 5 }, (_, index) => ({
id: `huge-${index}`,
x: 0,
y: 0,
radius: 50_000_000 - index * 1_000_000
}))
)
expect(
packed.every((worktree) => Number.isFinite(worktree.x) && Number.isFinite(worktree.y))
).toBe(true)
expect(numericMapSets).toBeLessThan(100)
} finally {
Map.prototype.set = set
}
})
})
@@ -1,267 +0,0 @@
import {
addAgentMapPackingCircle,
agentMapPackingCircleOverlaps,
AGENT_MAP_PACKING_SCORE_TOLERANCE,
AGENT_MAP_WORKTREE_GAP,
type AgentMapPackableCircle,
type AgentMapPackingSpatialIndex
} from './agent-map-packing-spatial-index'
export { AGENT_MAP_WORKTREE_GAP } from './agent-map-packing-spatial-index'
const PACKING_ANGLE_STEPS = 72
const MAX_PACKING_CANDIDATE_ANCHORS = 128
const MAX_DIRECT_OVERLAP_WORKTREES = 4
const LARGE_PACKING_THRESHOLD = 256
const VERY_LARGE_PACKING_THRESHOLD = 512
const SCORE_TOLERANCE = AGENT_MAP_PACKING_SCORE_TOLERANCE
const CENTER_DIRECTIONS = [
[-1, -1],
[0, -1],
[1, -1],
[-1, 0],
[1, 0],
[-1, 1],
[0, 1],
[1, 1]
] as const
type PackableWorktree = AgentMapPackableCircle
type PackingCandidate = {
x: number
y: number
enclosingRadius: number
distanceFromCenter: number
neighborDistance?: number
}
type PackingSearchBudget = {
angleSteps: number
candidateAnchors: number
}
function compareStable(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0
}
function hashFraction(value: string): number {
let hash = 2166136261
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index)
hash = Math.imul(hash, 16777619)
}
return (hash >>> 0) / 0xffffffff
}
function placedWorktreesOverlap(
candidate: Pick<PackableWorktree, 'x' | 'y' | 'radius'>,
placed: PackableWorktree[]
): boolean {
return placed.some(
(worktree) =>
Math.hypot(candidate.x - worktree.x, candidate.y - worktree.y) <
candidate.radius + worktree.radius + AGENT_MAP_WORKTREE_GAP - SCORE_TOLERANCE
)
}
function comparePackingScores(
a: PackingCandidate,
b: PackingCandidate,
placed: PackableWorktree[]
): number {
for (const key of ['enclosingRadius', 'distanceFromCenter'] as const) {
if (Math.abs(a[key] - b[key]) > SCORE_TOLERANCE) {
return a[key] - b[key]
}
}
a.neighborDistance ??= placed.reduce(
(sum, other) => sum + Math.hypot(a.x - other.x, a.y - other.y),
0
)
b.neighborDistance ??= placed.reduce(
(sum, other) => sum + Math.hypot(b.x - other.x, b.y - other.y),
0
)
return Math.abs(a.neighborDistance - b.neighborDistance) > SCORE_TOLERANCE
? a.neighborDistance - b.neighborDistance
: 0
}
function compareBoundaryAnchors(a: PackableWorktree, b: PackableWorktree): number {
return (
Math.hypot(b.x, b.y) + b.radius - (Math.hypot(a.x, a.y) + a.radius) || compareStable(a.id, b.id)
)
}
function addBoundaryAnchor(
boundaryAnchors: PackableWorktree[],
worktree: PackableWorktree,
maxAnchors: number
): void {
let low = 0
let high = boundaryAnchors.length
while (low < high) {
const middle = (low + high) >>> 1
if (compareBoundaryAnchors(worktree, boundaryAnchors[middle]) < 0) {
high = middle
} else {
low = middle + 1
}
}
boundaryAnchors.splice(low, 0, worktree)
if (boundaryAnchors.length > maxAnchors) {
boundaryAnchors.pop()
}
}
function placePackedWorktree(
worktree: PackableWorktree,
placed: PackableWorktree[],
boundaryAnchors: PackableWorktree[],
spatialIndex: AgentMapPackingSpatialIndex | null,
currentRadius: number,
searchBudget: PackingSearchBudget
): void {
let best: PackingCandidate | undefined
const anchors = placed.length <= searchBudget.candidateAnchors ? placed : boundaryAnchors
const scoreNeighbors =
searchBudget.candidateAnchors === MAX_PACKING_CANDIDATE_ANCHORS ? placed : anchors
for (const anchor of anchors) {
const orbit = anchor.radius + worktree.radius + AGENT_MAP_WORKTREE_GAP
const angleOffset = hashFraction(`${worktree.id}:${anchor.id}`) * Math.PI * 2
for (let step = 0; step < searchBudget.angleSteps; step += 1) {
const angle = angleOffset + (step / searchBudget.angleSteps) * Math.PI * 2
const x = anchor.x + Math.cos(angle) * orbit
const y = anchor.y + Math.sin(angle) * orbit
const overlapCandidate = { x, y, radius: worktree.radius }
if (
spatialIndex
? agentMapPackingCircleOverlaps(overlapCandidate, spatialIndex)
: placedWorktreesOverlap(overlapCandidate, placed)
) {
continue
}
const distanceFromCenter = Math.hypot(x, y)
const candidate = {
x,
y,
enclosingRadius: Math.max(currentRadius, distanceFromCenter + worktree.radius),
distanceFromCenter
}
if (!best || comparePackingScores(candidate, best, scoreNeighbors) < 0) {
best = candidate
}
}
}
if (best) {
worktree.x = best.x
worktree.y = best.y
return
}
let fallbackX = Number.NEGATIVE_INFINITY
for (const candidate of placed) {
fallbackX = Math.max(fallbackX, candidate.x + candidate.radius)
}
worktree.x = fallbackX + worktree.radius + AGENT_MAP_WORKTREE_GAP
worktree.y = 0
}
function enclosingRadius(worktrees: PackableWorktree[], x: number, y: number): number {
let radius = 0
for (const worktree of worktrees) {
radius = Math.max(radius, Math.hypot(worktree.x - x, worktree.y - y) + worktree.radius)
}
return radius
}
function packingSearchBudget(count: number): PackingSearchBudget {
if (count > VERY_LARGE_PACKING_THRESHOLD) {
return { angleSteps: 16, candidateAnchors: 12 }
}
if (count > LARGE_PACKING_THRESHOLD) {
return { angleSteps: 24, candidateAnchors: 64 }
}
return {
angleSteps: PACKING_ANGLE_STEPS,
candidateAnchors: MAX_PACKING_CANDIDATE_ANCHORS
}
}
function findEnclosingCenter(
worktrees: PackableWorktree[],
bounds: { left: number; right: number; top: number; bottom: number }
): { x: number; y: number } {
let x = (bounds.left + bounds.right) / 2
let y = (bounds.top + bounds.bottom) / 2
let radius = enclosingRadius(worktrees, x, y)
let step = Math.max(bounds.right - bounds.left, bounds.bottom - bounds.top) / 4
while (step > SCORE_TOLERANCE) {
let improved = false
for (const [dx, dy] of CENTER_DIRECTIONS) {
const candidateX = x + dx * step
const candidateY = y + dy * step
const candidateRadius = enclosingRadius(worktrees, candidateX, candidateY)
if (candidateRadius < radius - SCORE_TOLERANCE) {
x = candidateX
y = candidateY
radius = candidateRadius
improved = true
}
}
if (!improved) {
step /= 2
}
}
return { x, y }
}
export function packAgentMapWorktrees<T extends PackableWorktree>(worktrees: T[]): T[] {
const packed = [...worktrees].sort((a, b) => b.radius - a.radius || compareStable(a.id, b.id))
const placed: PackableWorktree[] = []
const boundaryAnchors: PackableWorktree[] = []
const searchBudget = packingSearchBudget(packed.length)
const spatialIndex: AgentMapPackingSpatialIndex | null =
packed.length > MAX_DIRECT_OVERLAP_WORKTREES ? new Map() : null
let currentRadius = 0
for (const worktree of packed) {
if (placed.length > 0) {
placePackedWorktree(
worktree,
placed,
boundaryAnchors,
spatialIndex,
currentRadius,
searchBudget
)
}
placed.push(worktree)
addBoundaryAnchor(boundaryAnchors, worktree, searchBudget.candidateAnchors)
if (spatialIndex) {
addAgentMapPackingCircle(spatialIndex, worktree)
}
currentRadius = Math.max(currentRadius, Math.hypot(worktree.x, worktree.y) + worktree.radius)
}
if (packed.length === 0) {
return packed
}
let left = Number.POSITIVE_INFINITY
let right = Number.NEGATIVE_INFINITY
let top = Number.POSITIVE_INFINITY
let bottom = Number.NEGATIVE_INFINITY
for (const worktree of packed) {
left = Math.min(left, worktree.x - worktree.radius)
right = Math.max(right, worktree.x + worktree.radius)
top = Math.min(top, worktree.y - worktree.radius)
bottom = Math.max(bottom, worktree.y + worktree.radius)
}
const center = findEnclosingCenter(packed, { left, right, top, bottom })
for (const worktree of packed) {
worktree.x -= center.x
worktree.y -= center.y
}
return packed.sort((a, b) => compareStable(a.id, b.id))
}
@@ -1,595 +0,0 @@
.agent-map-canvas {
background-image: radial-gradient(
color-mix(in srgb, var(--muted-foreground) 16%, transparent) 0.6px,
transparent 0.6px
);
background-size: 20px 20px;
}
.agent-map-canvas::before {
position: absolute;
inset: 0;
background: radial-gradient(
circle at 45% 48%,
color-mix(in srgb, var(--muted) 34%, transparent),
transparent 70%
);
content: '';
pointer-events: none;
}
.agent-map-project-ring {
fill: color-mix(in srgb, var(--card) 22%, transparent);
stroke: color-mix(in srgb, var(--ring) 58%, transparent);
stroke-width: 1.25;
transform-box: fill-box;
transform-origin: center;
transition:
fill 160ms ease,
stroke 160ms ease,
transform 220ms cubic-bezier(0.2, 1.35, 0.4, 1);
vector-effect: non-scaling-stroke;
}
/* Group hover includes nested contents; held state bridges pointer capture. */
:where(
.agent-map-project-node:hover,
.agent-map-project-node:focus-within,
.agent-map-project-node.is-held
)
.agent-map-project-ring {
fill: color-mix(in srgb, var(--card) 42%, transparent);
stroke: var(--ring);
transform: scale(1.018);
}
.agent-map-project-node,
.agent-map-worktree-group {
transform-box: fill-box;
transform-origin: center;
}
.agent-map-project-node.is-entering,
.agent-map-worktree-group.is-entering {
animation: agent-map-ring-enter 420ms cubic-bezier(0.2, 1.35, 0.4, 1) both;
}
.agent-map-project-node.is-exiting,
.agent-map-worktree-group.is-exiting {
opacity: 0;
pointer-events: none;
transform: scale(0.86);
transition:
opacity 220ms ease-in,
transform 260ms cubic-bezier(0.4, 0, 1, 1);
}
.agent-map-worktree-label {
fill: var(--foreground);
font-family: Geist, var(--font-sans);
font-weight: 600;
letter-spacing: 0.05em;
paint-order: stroke fill;
pointer-events: none;
stroke: var(--background);
stroke-linejoin: round;
stroke-width: 4px;
}
.agent-map-project-label-frame {
overflow: visible;
pointer-events: none;
}
.agent-map-project-label {
display: flex;
width: max-content;
max-width: calc(100% - 8px);
height: 100%;
align-items: center;
justify-content: center;
gap: 4px;
margin-inline: auto;
color: var(--foreground);
font-family: Geist, var(--font-sans);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.05em;
line-height: 1;
white-space: nowrap;
}
.agent-map-project-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
-webkit-text-stroke: 4px var(--background);
paint-order: stroke fill;
}
.agent-map-project-count,
.agent-map-worktree-count {
fill: var(--muted-foreground);
font-family: Geist, var(--font-sans);
letter-spacing: 0.05em;
paint-order: stroke fill;
pointer-events: none;
stroke: var(--background);
stroke-linejoin: round;
stroke-width: 4px;
}
.agent-map-project-count {
font-size: 11px;
text-anchor: middle;
}
.agent-map-worktree-ring {
cursor: pointer;
fill: color-mix(in srgb, var(--card) 48%, transparent);
outline: none;
stroke: color-mix(in srgb, var(--muted-foreground) 42%, transparent);
stroke-width: 1;
transform-box: fill-box;
transform-origin: center;
transition:
fill 160ms ease,
stroke 160ms ease,
stroke-width 160ms ease,
transform 210ms cubic-bezier(0.2, 1.35, 0.4, 1);
vector-effect: non-scaling-stroke;
}
.agent-map-worktree-status-glow {
fill: none;
pointer-events: none;
stroke-width: 9;
vector-effect: non-scaling-stroke;
}
.agent-map-worktree-status-glow.fleet-status-blocked {
stroke: color-mix(in srgb, var(--color-red-500) 28%, transparent);
}
.agent-map-worktree-status-glow.fleet-status-waiting {
stroke: color-mix(in srgb, var(--agent-question) 32%, transparent);
}
.agent-map-worktree-status-glow.fleet-status-working {
stroke: color-mix(in srgb, var(--color-yellow-500) 28%, transparent);
}
.agent-map-worktree-status-glow.fleet-status-done {
stroke: color-mix(in srgb, var(--color-emerald-500) 34%, transparent);
}
:where(
.agent-map-worktree-group:hover,
.agent-map-worktree-group:focus-within,
.agent-map-worktree-group.is-held
)
.agent-map-worktree-ring {
fill: color-mix(in srgb, var(--card) 72%, transparent);
stroke: var(--ring);
transform: scale(1.035);
}
.agent-map-worktree-ring:focus-visible {
fill: color-mix(in srgb, var(--ring) 8%, var(--card));
outline: none;
stroke: var(--ring);
stroke-width: 2;
}
.agent-map-worktree-ring.is-selected {
fill: color-mix(in srgb, var(--ring) 6%, var(--card));
stroke: var(--ring);
stroke-width: 1.5;
}
.agent-map-worktree-ring.is-open {
fill: color-mix(in srgb, var(--ring) 18%, var(--card));
stroke: var(--ring);
stroke-width: 2.5;
}
.agent-map-worktree-ring.is-working {
stroke: var(--color-yellow-500);
}
.agent-map-worktree-ring.is-waiting {
stroke: var(--agent-question);
}
.agent-map-worktree-ring.is-blocked {
stroke: var(--color-red-500);
}
/* Whole workspace has settled and something in it is still unread. */
.agent-map-worktree-ring.is-done {
stroke: var(--color-emerald-500);
}
.agent-map-worktree-label {
font-size: 12px;
font-weight: 500;
letter-spacing: 0.01em;
text-anchor: middle;
}
.agent-map-worktree-count {
font-size: 11px;
text-anchor: middle;
}
.agent-map-worktree-label-layer,
.agent-map-worktree-hover-label-layer {
pointer-events: none;
}
.agent-map-worktree-label-group {
opacity: 0;
pointer-events: none;
transition: opacity 180ms ease;
}
.agent-map-worktree-label-group.is-visible,
.agent-map-worktree-label-group.is-active {
opacity: 1;
}
.agent-map-worktree-count {
opacity: 0;
}
.agent-map-worktree-label-group.is-count-visible .agent-map-worktree-count {
opacity: 1;
}
.agent-map-worktree-label-group.is-exiting {
opacity: 0;
}
.agent-map-agent-node {
cursor: pointer;
outline: none;
transition:
filter 180ms ease-in,
opacity 180ms ease-in;
}
.agent-map-agent-visual {
transform-box: fill-box;
transform-origin: center;
transition: transform 200ms cubic-bezier(0.2, 1.35, 0.4, 1);
}
.agent-map-agent-node:hover .agent-map-agent-visual,
.agent-map-agent-node:focus-visible .agent-map-agent-visual {
transform: scale(1.12);
}
.agent-map-agent-node.is-entering .agent-map-agent-visual {
animation: agent-map-agent-enter 420ms cubic-bezier(0.2, 1.35, 0.4, 1) both;
}
.agent-map-agent-node.is-exiting {
filter: blur(1px);
opacity: 0;
pointer-events: none;
}
.agent-map-agent-node.is-exiting .agent-map-agent-visual {
transform: scale(0.58);
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
}
.agent-map-worktree-lineage-link {
fill: none;
pointer-events: none;
stroke: color-mix(in srgb, var(--muted-foreground) 34%, transparent);
stroke-linecap: round;
stroke-width: 1.25;
transition: opacity 180ms ease;
vector-effect: non-scaling-stroke;
}
.agent-map-lineage-link {
fill: none;
pointer-events: none;
stroke: color-mix(in srgb, var(--muted-foreground) 42%, transparent);
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1;
transition: opacity 180ms ease;
vector-effect: non-scaling-stroke;
}
.agent-map-worktree-lineage-link.is-entering,
.agent-map-lineage-link.is-entering {
animation: agent-map-link-enter 260ms ease-out both;
}
.agent-map-worktree-lineage-link.is-exiting,
.agent-map-lineage-link.is-exiting {
opacity: 0;
}
.agent-map-agent-hit {
fill: transparent;
stroke: transparent;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
.agent-map-agent-mark {
fill: var(--background);
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
.agent-map-agent-status-flare {
fill: none;
pointer-events: none;
stroke-width: 2;
transform-box: fill-box;
transform-origin: center;
vector-effect: non-scaling-stroke;
/* Keep in step with AGENT_MAP_STATUS_FLARE_MS, which gates how long the element stays
mounted. The performance test asserts the two agree. */
animation: agent-map-status-flare 1400ms cubic-bezier(0.25, 0.5, 0.25, 1) both;
}
.agent-map-agent-status-flare.fleet-status-waiting {
stroke: var(--agent-question);
}
.agent-map-agent-status-flare.fleet-status-done {
stroke: var(--color-emerald-500);
}
@keyframes agent-map-status-flare {
0% {
opacity: 0.9;
transform: scale(0.62);
}
100% {
opacity: 0;
transform: scale(2.5);
}
}
.agent-map-agent-status-glow {
fill: none;
pointer-events: none;
stroke-width: 7;
vector-effect: non-scaling-stroke;
}
.agent-map-agent-status-glow.fleet-status-working {
stroke: color-mix(in srgb, var(--color-yellow-500) 32%, transparent);
}
.agent-map-agent-status-glow.fleet-status-waiting {
stroke: color-mix(in srgb, var(--agent-question) 48%, transparent);
}
.agent-map-agent-status-glow.fleet-status-blocked {
stroke: color-mix(in srgb, var(--color-red-500) 38%, transparent);
}
/* Unread finishes only. `fleet-status-done-seen` gets no glow — that is the whole
difference between "you have not looked at this" and "you have". */
.agent-map-agent-status-glow.fleet-status-done {
stroke: color-mix(in srgb, var(--color-emerald-500) 46%, transparent);
}
.agent-map-agent-icon {
overflow: visible;
pointer-events: none;
}
.agent-map-agent-icon > div {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
color: var(--foreground);
}
.agent-map-agent-node:hover .agent-map-agent-hit,
.agent-map-agent-node:focus-visible .agent-map-agent-hit {
fill: color-mix(in srgb, var(--ring) 10%, transparent);
stroke: var(--ring);
}
.agent-map-agent-node.is-selected .agent-map-agent-hit {
fill: color-mix(in srgb, var(--ring) 18%, transparent);
stroke: var(--ring);
stroke-width: 3;
}
.agent-map-agent-node.is-selected .agent-map-agent-mark {
fill: color-mix(in srgb, var(--ring) 10%, var(--background));
}
.fleet-status-working .agent-map-agent-mark {
stroke: var(--color-yellow-500);
}
.fleet-status-monitoring .agent-map-agent-mark {
stroke: var(--color-yellow-500);
}
.fleet-status-blocked .agent-map-agent-mark {
stroke: var(--color-red-500);
}
.fleet-status-waiting .agent-map-agent-mark {
stroke: var(--agent-question);
}
/* Unread: filled core. Fill survives zoom-out further than a halo does, and it is the
one channel still legible once the node is a few pixels wide. */
.fleet-status-done .agent-map-agent-mark {
fill: color-mix(in srgb, var(--color-emerald-500) 38%, var(--background));
stroke: var(--color-emerald-500);
stroke-width: 2;
}
/* Seen: hollow, and still unmistakably green — you read it, you have not landed it. */
.fleet-status-done-seen .agent-map-agent-mark {
stroke: color-mix(in srgb, var(--color-emerald-500) 62%, transparent);
}
.fleet-status-idle .agent-map-agent-mark {
stroke: color-mix(in srgb, var(--color-neutral-500) 55%, transparent);
}
.agent-map-agent-unread-mark {
fill: var(--color-amber-500);
pointer-events: none;
stroke: var(--background);
stroke-width: 2;
}
/* Shape cue for 'waiting': hue alone can't carry it at low zoom or for
red-green CVD, where orange and blocked-red converge. */
.agent-map-agent-question-backdrop {
fill: var(--background);
pointer-events: none;
stroke: none;
}
.agent-map-agent-question-icon {
overflow: visible;
pointer-events: none;
}
.agent-map-aggregate-node circle {
fill: color-mix(in srgb, var(--muted-foreground) 12%, var(--card));
stroke: color-mix(in srgb, var(--muted-foreground) 46%, transparent);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.agent-map-aggregate-node text {
fill: var(--muted-foreground);
font-family: Geist, var(--font-sans);
font-size: 11px;
font-weight: 600;
text-anchor: middle;
}
.agent-map-legend-dot {
display: block;
width: 7px;
height: 7px;
border-radius: 9999px;
background: var(--muted-foreground);
opacity: 0.42;
}
.agent-map-legend-dot.fleet-status-working {
border: 2px solid var(--color-yellow-500);
border-top-color: transparent;
background: transparent;
opacity: 1;
}
.agent-map-legend-dot.fleet-status-blocked {
background: var(--color-red-500);
opacity: 1;
}
.agent-map-legend-dot.fleet-status-waiting {
background: var(--agent-question);
box-shadow: 0 0 4px color-mix(in srgb, var(--agent-question) 72%, transparent);
opacity: 1;
}
.agent-map-legend-dot.fleet-status-done {
background: var(--color-emerald-500);
opacity: 1;
}
@keyframes agent-map-agent-enter {
0% {
opacity: 0;
transform: scale(0.45);
}
65% {
opacity: 1;
transform: scale(1.08);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes agent-map-ring-enter {
0% {
opacity: 0;
transform: scale(0.76);
}
72% {
opacity: 1;
transform: scale(1.025);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes agent-map-link-enter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.agent-map-project-ring,
.agent-map-project-node,
.agent-map-worktree-ring,
.agent-map-worktree-group,
.agent-map-worktree-label-group,
.agent-map-agent-node,
.agent-map-agent-visual,
.agent-map-agent-mark,
.agent-map-agent-status-flare,
.agent-map-lineage-link,
.agent-map-worktree-lineage-link {
animation: none;
transition: none;
}
/* No flare without motion — the halo and filled core already carry the state. */
.agent-map-agent-status-flare {
display: none;
}
:where(
.agent-map-project-node:hover,
.agent-map-project-node:focus-within,
.agent-map-project-node.is-held
)
.agent-map-project-ring,
:where(
.agent-map-worktree-group:hover,
.agent-map-worktree-group:focus-within,
.agent-map-worktree-group.is-held
)
.agent-map-worktree-ring,
.agent-map-agent-node:hover .agent-map-agent-visual,
.agent-map-agent-node:focus-visible .agent-map-agent-visual {
transform: none;
}
}
@@ -1,35 +0,0 @@
import { useEffect, useState, type RefObject } from 'react'
export type AgentMapCanvasSize = { width: number; height: number }
export function useAgentMapCanvasSize(
containerRef: RefObject<HTMLDivElement | null>,
onResize: () => void
): AgentMapCanvasSize {
const [size, setSize] = useState<AgentMapCanvasSize>({ width: 800, height: 560 })
useEffect(() => {
const container = containerRef.current
if (!container || typeof ResizeObserver === 'undefined') {
return
}
const measure = (): void => {
const next = container.getBoundingClientRect()
if (next.width <= 0 || next.height <= 0) {
return
}
onResize()
setSize((current) =>
current.width === next.width && current.height === next.height
? current
: { width: next.width, height: next.height }
)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(container)
return () => observer.disconnect()
}, [containerRef, onResize])
return size
}
@@ -1,156 +0,0 @@
import { useCallback, useRef, useState } from 'react'
import type {
DashboardSleepWorkspaceArgs,
DashboardSpawnAgentArgs
} from '../../../../shared/dashboard-snapshot'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { AgentMapProjectRing, AgentMapWorktreeRing } from './agent-map-layout'
import {
AgentMapSnapshotWorkspaceMenu,
type AgentMapSnapshotWorkspaceMenuRequest
} from './AgentMapSnapshotWorkspaceMenu'
import {
AgentMapProjectContextMenuLoader,
type AgentMapProjectContextMenuRequest
} from './AgentMapProjectContextMenuLoader'
import {
AgentMapWorkspaceContextMenuLoader,
type AgentMapWorkspaceContextMenuRequest
} from './AgentMapWorkspaceContextMenuLoader'
type UseAgentMapContextMenusArgs = {
/** True only where the app store lives; the pop-out gets the snapshot menu. */
enabled: boolean
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
onOpenChange?: (open: boolean) => void
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void
}
export function useAgentMapContextMenus({
enabled,
launchableAgentsByWorktreeId,
onOpenChange,
onSpawnAgent,
onSleepWorkspace
}: UseAgentMapContextMenusArgs): {
contextMenus: React.JSX.Element | null
onOpenProjectContextMenu?: (
event: React.MouseEvent<SVGCircleElement>,
project: AgentMapProjectRing
) => void
onOpenWorkspaceContextMenu?: (
event: React.MouseEvent<SVGCircleElement>,
worktree: AgentMapWorktreeRing
) => void
} {
const requestIdRef = useRef(0)
const [workspaceRequest, setWorkspaceRequest] =
useState<AgentMapWorkspaceContextMenuRequest | null>(null)
const [projectRequest, setProjectRequest] = useState<AgentMapProjectContextMenuRequest | null>(
null
)
const [snapshotRequest, setSnapshotRequest] =
useState<AgentMapSnapshotWorkspaceMenuRequest | null>(null)
const snapshotMenuEnabled =
!enabled && (onSpawnAgent !== undefined || onSleepWorkspace !== undefined)
const openSnapshotWorkspaceMenu = useCallback(
(event: React.MouseEvent<SVGCircleElement>, worktree: AgentMapWorktreeRing): void => {
requestIdRef.current += 1
setSnapshotRequest({
id: requestIdRef.current,
worktreeId: worktree.worktreeId,
worktreeName: worktree.name,
launchableAgents: launchableAgentsByWorktreeId?.[worktree.worktreeId] ?? [],
clientX: event.clientX,
clientY: event.clientY
})
},
[launchableAgentsByWorktreeId]
)
const openWorkspaceContextMenu = useCallback(
(event: React.MouseEvent<SVGCircleElement>, worktree: AgentMapWorktreeRing): void => {
requestIdRef.current += 1
setProjectRequest(null)
setWorkspaceRequest({
id: requestIdRef.current,
worktreeId: worktree.worktreeId,
executionHostId: worktree.executionHostId,
clientX: event.clientX,
clientY: event.clientY,
altKey: event.altKey
})
},
[]
)
const openProjectContextMenu = useCallback(
(event: React.MouseEvent<SVGCircleElement>, project: AgentMapProjectRing): void => {
requestIdRef.current += 1
setWorkspaceRequest(null)
setProjectRequest({
id: requestIdRef.current,
projectId: project.id,
clientX: event.clientX,
clientY: event.clientY
})
},
[]
)
const handleWorkspaceLifecycleComplete = useCallback((): void => {
setWorkspaceRequest(null)
}, [])
const handleProjectOpenChange = useCallback(
(open: boolean): void => {
onOpenChange?.(open)
if (!open) {
setProjectRequest(null)
}
},
[onOpenChange]
)
const handleSnapshotOpenChange = useCallback(
(open: boolean): void => {
onOpenChange?.(open)
if (!open) {
setSnapshotRequest(null)
}
},
[onOpenChange]
)
const contextMenus = snapshotMenuEnabled ? (
snapshotRequest ? (
<AgentMapSnapshotWorkspaceMenu
request={snapshotRequest}
onOpenChange={handleSnapshotOpenChange}
onSpawnAgent={onSpawnAgent}
onSleepWorkspace={onSleepWorkspace}
/>
) : null
) : enabled ? (
<>
{workspaceRequest ? (
<AgentMapWorkspaceContextMenuLoader
request={workspaceRequest}
onOpenChange={onOpenChange}
onLifecycleComplete={handleWorkspaceLifecycleComplete}
/>
) : null}
{projectRequest ? (
<AgentMapProjectContextMenuLoader
request={projectRequest}
onOpenChange={handleProjectOpenChange}
/>
) : null}
</>
) : null
return {
contextMenus,
onOpenProjectContextMenu: enabled ? openProjectContextMenu : undefined,
onOpenWorkspaceContextMenu: enabled
? openWorkspaceContextMenu
: snapshotMenuEnabled
? openSnapshotWorkspaceMenu
: undefined
}
}
@@ -1,69 +0,0 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { AGENT_MAP_TIME_FIELDS, AGENT_MAP_TIME_MAX_INDEX } from './agent-map-time-filter'
import { useAgentMapFilters } from './useAgentMapFilters'
describe('useAgentMapFilters', () => {
it('resets states without clearing the other map filters', () => {
const hook = renderHook(() => useAgentMapFilters(['claude', 'codex']))
act(() => hook.result.current.applyQuickView('stuck'))
act(() => hook.result.current.resetStates())
expect([...hook.result.current.states]).toEqual(['attention', 'working', 'done', 'idle'])
expect(hook.result.current.timeRanges.sinceMessage).toEqual({
min: 4,
max: AGENT_MAP_TIME_MAX_INDEX
})
expect(hook.result.current.activeCount).toBe(1)
})
it('preserves a muted agent type across disappearance and reappearance', () => {
let agentTypes = ['claude', 'codex']
const hook = renderHook(() => useAgentMapFilters(agentTypes))
act(() => hook.result.current.toggleAgentType('claude'))
agentTypes = ['codex']
hook.rerender()
expect([...hook.result.current.agentTypes]).toEqual(['codex'])
expect(hook.result.current.activeCount).toBe(0)
agentTypes = ['claude', 'codex']
hook.rerender()
expect([...hook.result.current.agentTypes]).toEqual(['codex'])
expect(hook.result.current.activeCount).toBe(1)
})
it('enables a newly discovered agent type', () => {
let agentTypes = ['claude']
const hook = renderHook(() => useAgentMapFilters(agentTypes))
agentTypes = ['claude', 'grok']
hook.rerender()
expect([...hook.result.current.agentTypes]).toEqual(['claude', 'grok'])
})
it('preserves each time-range identity across unrelated facet updates', () => {
let agentTypes = ['claude', 'codex']
const hook = renderHook(() => useAgentMapFilters(agentTypes))
const ranges = hook.result.current.timeRanges
const fields = AGENT_MAP_TIME_FIELDS.map((field) => ranges[field])
act(() => hook.result.current.toggleState('done'))
act(() => hook.result.current.toggleAgentType('claude'))
act(() => hook.result.current.setUnreadOnly(true))
act(() => hook.result.current.setOrchestrationOnly(true))
agentTypes = ['claude', 'codex', 'grok']
hook.rerender()
expect(hook.result.current.timeRanges).toBe(ranges)
AGENT_MAP_TIME_FIELDS.forEach((field, index) => {
expect(hook.result.current.timeRanges[field]).toBe(fields[index])
})
})
})
@@ -1,105 +0,0 @@
import { useCallback, useMemo, useState } from 'react'
import type { AgentMapState } from './agent-map-filter'
import {
applyAgentMapQuickView,
emptyAgentMapFilterState,
ALL_AGENT_MAP_STATES,
type AgentMapFilterState,
type AgentMapQuickViewId
} from './agent-map-quick-views'
import {
activeAgentMapTimeFields,
fullAgentMapTimeRanges,
type AgentMapTimeField,
type AgentMapTimeRange
} from './agent-map-time-filter'
export type AgentMapFilterControls = AgentMapFilterState & {
activeCount: number
toggleState: (state: AgentMapState) => void
resetStates: () => void
toggleAgentType: (agentType: string) => void
setTimeRange: (field: AgentMapTimeField, range: AgentMapTimeRange) => void
resetTimeRanges: () => void
setUnreadOnly: (only: boolean) => void
setOrchestrationOnly: (only: boolean) => void
applyQuickView: (id: AgentMapQuickViewId) => void
reset: () => void
}
type AgentMapFacetState = Omit<AgentMapFilterState, 'agentTypes'>
function toggle<T>(current: ReadonlySet<T>, value: T): Set<T> {
const next = new Set(current)
if (!next.delete(value)) {
next.add(value)
}
return next
}
function mapFacets(state: AgentMapFilterState): AgentMapFacetState {
const { agentTypes: _agentTypes, ...facets } = state
return facets
}
/** Map-only filter state. It lives on the board rather than inside the map so
* the shared toolbar filter — the map has no rail of its own — can drive it. */
export function useAgentMapFilters(agentTypes: readonly string[]): AgentMapFilterControls {
const [filters, setFilters] = useState<AgentMapFacetState>(() =>
mapFacets(emptyAgentMapFilterState(agentTypes))
)
const [mutedAgentTypes, setMutedAgentTypes] = useState<ReadonlySet<string>>(() => new Set())
const enabledAgentTypes = useMemo(
() => new Set(agentTypes.filter((agentType) => !mutedAgentTypes.has(agentType))),
[agentTypes, mutedAgentTypes]
)
const patch = useCallback(
(next: Partial<AgentMapFacetState>) => setFilters((current) => ({ ...current, ...next })),
[]
)
const activeCount =
(filters.states.size === ALL_AGENT_MAP_STATES.length ? 0 : 1) +
(enabledAgentTypes.size === agentTypes.length ? 0 : 1) +
activeAgentMapTimeFields(filters.timeRanges).length +
(filters.unreadOnly ? 1 : 0) +
(filters.orchestrationOnly ? 1 : 0)
return {
...filters,
agentTypes: enabledAgentTypes,
activeCount,
toggleState: useCallback(
(state) => setFilters((c) => ({ ...c, states: toggle(c.states, state) })),
[]
),
resetStates: useCallback(
() => patch({ states: new Set<AgentMapState>(ALL_AGENT_MAP_STATES) }),
[patch]
),
toggleAgentType: useCallback(
(agentType) => setMutedAgentTypes((current) => toggle(current, agentType)),
[]
),
setTimeRange: useCallback(
(field, range) =>
setFilters((c) => ({ ...c, timeRanges: { ...c.timeRanges, [field]: range } })),
[]
),
resetTimeRanges: useCallback(() => patch({ timeRanges: fullAgentMapTimeRanges() }), [patch]),
setUnreadOnly: useCallback((only) => patch({ unreadOnly: only }), [patch]),
setOrchestrationOnly: useCallback((only) => patch({ orchestrationOnly: only }), [patch]),
applyQuickView: useCallback(
(id) => {
setFilters(mapFacets(applyAgentMapQuickView(id, agentTypes)))
setMutedAgentTypes(new Set())
},
[agentTypes]
),
reset: useCallback(() => {
setFilters(mapFacets(emptyAgentMapFilterState(agentTypes)))
setMutedAgentTypes(new Set())
}, [agentTypes])
}
}
@@ -1,279 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type {
AgentMapAgentNode,
AgentMapLayout,
AgentMapProjectRing,
AgentMapWorktreeRing
} from './agent-map-layout'
export const AGENT_MAP_EXIT_DURATION_MS = 260
export const AGENT_MAP_ENTER_DURATION_MS = 420
function allAgentIds(layout: AgentMapLayout): Set<string> {
return new Set(
layout.projects.flatMap((project) =>
project.worktrees.flatMap((worktree) => worktree.agents.map((agent) => agent.card.paneKey))
)
)
}
function allWorktreeIds(layout: AgentMapLayout): Set<string> {
return new Set(
layout.projects.flatMap((project) => project.worktrees.map((worktree) => worktree.id))
)
}
function retainMotionState<T extends { motionState?: 'entering' | 'exiting' }>(
previous: T | undefined,
next: T
): T {
return {
...next,
motionState: !previous
? 'entering'
: previous.motionState === 'entering'
? 'entering'
: undefined
}
}
function reconcileAgents(
previous: AgentMapWorktreeRing,
next: AgentMapWorktreeRing,
nextAgentIds: ReadonlySet<string>
): AgentMapAgentNode[] {
const previousById = new Map(previous.agents.map((agent) => [agent.card.paneKey, agent]))
const nextIds = new Set(next.agents.map((agent) => agent.card.paneKey))
const agents = next.agents.map((agent) =>
retainMotionState(previousById.get(agent.card.paneKey), agent)
)
for (const agent of previous.agents) {
if (!nextIds.has(agent.card.paneKey) && !nextAgentIds.has(agent.card.paneKey)) {
agents.push({ ...agent, motionState: 'exiting' })
}
}
return agents
}
function enteringWorktree(worktree: AgentMapWorktreeRing): AgentMapWorktreeRing {
return {
...worktree,
motionState: 'entering',
agents: worktree.agents.map((agent) => ({ ...agent, motionState: undefined }))
}
}
function exitingWorktree(worktree: AgentMapWorktreeRing): AgentMapWorktreeRing {
return {
...worktree,
motionState: 'exiting',
agents: worktree.agents.map((agent) => ({ ...agent, motionState: undefined }))
}
}
function reconcileWorktrees(
previous: AgentMapProjectRing,
next: AgentMapProjectRing,
nextAgentIds: ReadonlySet<string>,
nextWorktreeIds: ReadonlySet<string>
): AgentMapWorktreeRing[] {
const previousById = new Map(previous.worktrees.map((worktree) => [worktree.id, worktree]))
const nextIds = new Set(next.worktrees.map((worktree) => worktree.id))
const worktrees = next.worktrees.map((worktree) => {
const previousWorktree = previousById.get(worktree.id)
if (!previousWorktree) {
return enteringWorktree(worktree)
}
return {
...retainMotionState(previousWorktree, worktree),
agents: reconcileAgents(previousWorktree, worktree, nextAgentIds)
}
})
for (const worktree of previous.worktrees) {
if (!nextIds.has(worktree.id) && !nextWorktreeIds.has(worktree.id)) {
worktrees.push(exitingWorktree(worktree))
}
}
return worktrees
}
function enteringProject(project: AgentMapProjectRing): AgentMapProjectRing {
return {
...project,
motionState: 'entering',
worktrees: project.worktrees.map((worktree) => ({
...worktree,
motionState: undefined,
agents: worktree.agents.map((agent) => ({ ...agent, motionState: undefined }))
}))
}
}
function exitingProject(project: AgentMapProjectRing): AgentMapProjectRing {
return {
...project,
motionState: 'exiting',
worktrees: project.worktrees.map((worktree) => ({
...worktree,
motionState: undefined,
agents: worktree.agents.map((agent) => ({ ...agent, motionState: undefined }))
}))
}
}
export function reconcileAgentMapMotionLayout(
previous: AgentMapLayout,
next: AgentMapLayout
): AgentMapLayout {
const previousById = new Map(previous.projects.map((project) => [project.id, project]))
const nextProjectIds = new Set(next.projects.map((project) => project.id))
const nextAgentIds = allAgentIds(next)
const nextWorktreeIds = allWorktreeIds(next)
const projects = next.projects.map((project) => {
const previousProject = previousById.get(project.id)
if (!previousProject) {
return enteringProject(project)
}
return {
...retainMotionState(previousProject, project),
worktrees: reconcileWorktrees(previousProject, project, nextAgentIds, nextWorktreeIds)
}
})
for (const project of previous.projects) {
if (!nextProjectIds.has(project.id)) {
projects.push(exitingProject(project))
}
}
return {
...next,
projects
}
}
function motionNodeSignature(layout: AgentMapLayout, motionState: 'entering' | 'exiting'): string {
const nodeIds = layout.projects.flatMap((project) => [
...(project.motionState === motionState ? [`project:${project.id}`] : []),
...project.worktrees.flatMap((worktree) => [
...(worktree.motionState === motionState ? [`worktree:${worktree.id}`] : []),
...worktree.agents
.filter((agent) => agent.motionState === motionState)
.map((agent) => `agent:${agent.card.paneKey}`)
])
])
return nodeIds.length > 0 ? JSON.stringify(nodeIds) : ''
}
function clearEnteringAgentMapLayout(layout: AgentMapLayout): AgentMapLayout {
return {
...layout,
projects: layout.projects.map((project) => ({
...project,
motionState: project.motionState === 'entering' ? undefined : project.motionState,
worktrees: project.worktrees.map((worktree) => ({
...worktree,
motionState: worktree.motionState === 'entering' ? undefined : worktree.motionState,
agents: worktree.agents.map((agent) => ({
...agent,
motionState: agent.motionState === 'entering' ? undefined : agent.motionState
}))
}))
}))
}
}
export function pruneExitingAgentMapLayout(layout: AgentMapLayout): AgentMapLayout {
return {
...layout,
projects: layout.projects
.filter((project) => project.motionState !== 'exiting')
.map((project) => ({
...project,
worktrees: project.worktrees
.filter((worktree) => worktree.motionState !== 'exiting')
.map((worktree) => ({
...worktree,
agents: worktree.agents.filter((agent) => agent.motionState !== 'exiting')
}))
}))
}
}
export function useAgentMapMotionLayout(
layout: AgentMapLayout,
reducedMotion: boolean
): AgentMapLayout {
const [motionState, setMotionState] = useState(() => ({
inputLayout: layout,
reducedMotion,
motionLayout: layout
}))
const enterTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const exitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
let motionLayout = motionState.motionLayout
// Reconcile before commit so metadata refreshes do not render the full scene twice.
if (motionState.inputLayout !== layout || motionState.reducedMotion !== reducedMotion) {
motionLayout = reducedMotion
? layout
: reconcileAgentMapMotionLayout(motionState.motionLayout, layout)
setMotionState({ inputLayout: layout, reducedMotion, motionLayout })
}
const { enteringSignature, exitingSignature } = useMemo(
() => ({
enteringSignature: motionNodeSignature(motionLayout, 'entering'),
exitingSignature: motionNodeSignature(motionLayout, 'exiting')
}),
[motionLayout]
)
useEffect(() => {
if (enterTimerRef.current) {
clearTimeout(enterTimerRef.current)
enterTimerRef.current = null
}
if (reducedMotion || !enteringSignature) {
return
}
enterTimerRef.current = setTimeout(() => {
enterTimerRef.current = null
setMotionState((previous) => ({
...previous,
motionLayout: clearEnteringAgentMapLayout(previous.motionLayout)
}))
}, AGENT_MAP_ENTER_DURATION_MS)
return () => {
if (enterTimerRef.current) {
clearTimeout(enterTimerRef.current)
enterTimerRef.current = null
}
}
}, [enteringSignature, reducedMotion])
useEffect(() => {
if (exitTimerRef.current) {
clearTimeout(exitTimerRef.current)
exitTimerRef.current = null
}
if (reducedMotion || !exitingSignature) {
return
}
exitTimerRef.current = setTimeout(() => {
exitTimerRef.current = null
setMotionState((previous) => ({
...previous,
motionLayout: pruneExitingAgentMapLayout(previous.motionLayout)
}))
}, AGENT_MAP_EXIT_DURATION_MS)
return () => {
if (exitTimerRef.current) {
clearTimeout(exitTimerRef.current)
exitTimerRef.current = null
}
}
}, [exitingSignature, reducedMotion])
return motionLayout
}
@@ -1,47 +0,0 @@
import { useCallback, useState, type RefObject } from 'react'
export type AgentMapPointerHold = {
projectId: string | null
worktreeId: string | null
}
type AgentMapPointerDragRef = RefObject<{ pointerId: number } | null>
function closestId(target: Element, attribute: string): string | null {
return target.closest(`[${attribute}]`)?.getAttribute(attribute) ?? null
}
/**
* Remembers which rings a pan drag started in. Pointer capture retargets
* `:hover` to the `<svg>` for the whole gesture, so the ring under the pointer
* would otherwise collapse until the gesture ends.
*/
export function useAgentMapPointerHold(dragRef: AgentMapPointerDragRef): {
held: AgentMapPointerHold | null
hold: (target: Element) => void
release: () => void
clearDrag: (pointerId: number) => boolean
} {
const [held, setHeld] = useState<AgentMapPointerHold | null>(null)
const hold = useCallback((target: Element): void => {
const projectId = closestId(target, 'data-agent-map-project-id')
const worktreeId = closestId(target, 'data-agent-map-worktree-id')
// A pan off empty canvas holds nothing, so leave the memoized scene alone.
setHeld(projectId === null && worktreeId === null ? null : { projectId, worktreeId })
}, [])
const release = useCallback((): void => {
setHeld((current) => (current === null ? current : null))
}, [])
const clearDrag = useCallback(
(pointerId: number): boolean => {
if (dragRef.current?.pointerId !== pointerId) {
return false
}
dragRef.current = null
release()
return true
},
[dragRef, release]
)
return { held, hold, release, clearDrag }
}
@@ -1,63 +0,0 @@
import { useEffect, useRef } from 'react'
import type { AgentMapAgentNode } from './agent-map-layout'
import type { AgentMapViewport } from './agent-map-viewport-transition'
type AgentMapSelectedFocusOptions = {
agents: AgentMapAgentNode[]
selectedPaneKey: string | null
viewportRef: { current: AgentMapViewport }
resolveFocusZoom: () => number
animateViewport: (from: AgentMapViewport, to: AgentMapViewport) => void
stopViewportTransition: () => void
}
export function useAgentMapSelectedFocus({
agents,
selectedPaneKey,
viewportRef,
resolveFocusZoom,
animateViewport,
stopViewportTransition
}: AgentMapSelectedFocusOptions): void {
const focusedAgentRef = useRef<{
paneKey: string
x: number
y: number
zoom: number
} | null>(null)
useEffect(() => {
const selected = agents.find((agent) => agent.card.paneKey === selectedPaneKey)
if (!selectedPaneKey || !selected) {
focusedAgentRef.current = null
stopViewportTransition()
return
}
const targetZoom = resolveFocusZoom()
const focused = focusedAgentRef.current
if (
focused?.paneKey === selectedPaneKey &&
focused.x === selected.x &&
focused.y === selected.y &&
focused.zoom === targetZoom
) {
return
}
focusedAgentRef.current = {
paneKey: selectedPaneKey,
x: selected.x,
y: selected.y,
zoom: targetZoom
}
animateViewport(viewportRef.current, {
center: { x: selected.x, y: selected.y },
zoom: targetZoom
})
}, [
agents,
animateViewport,
resolveFocusZoom,
selectedPaneKey,
stopViewportTransition,
viewportRef
])
}
@@ -1,51 +0,0 @@
import { useCallback, useEffect, useRef } from 'react'
import {
startAgentMapViewportTransition,
type AgentMapViewport
} from './agent-map-viewport-transition'
type AgentMapViewportTransitionOptions = {
durationMs: number
reducedMotion: boolean
onFrame: (viewport: AgentMapViewport) => void
}
export function useAgentMapViewportTransition({
durationMs,
reducedMotion,
onFrame
}: AgentMapViewportTransitionOptions): {
animate: (from: AgentMapViewport, to: AgentMapViewport) => void
stop: () => void
} {
const cancelRef = useRef<(() => void) | null>(null)
const stop = useCallback((): void => {
cancelRef.current?.()
cancelRef.current = null
}, [])
const animate = useCallback(
(from: AgentMapViewport, to: AgentMapViewport): void => {
stop()
if (reducedMotion) {
onFrame(to)
return
}
let cancel = (): void => {}
cancel = startAgentMapViewportTransition({
from,
to,
durationMs,
onFrame,
onComplete: () => {
if (cancelRef.current === cancel) {
cancelRef.current = null
}
}
})
cancelRef.current = cancel
},
[durationMs, onFrame, reducedMotion, stop]
)
useEffect(() => stop, [stop])
return { animate, stop }
}
@@ -93,16 +93,6 @@ describe('AgentDashboardDrawer', () => {
expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false)
})
it('does not hand the drawer over to an agent map popout', () => {
render(<AgentDashboardDrawer statusBarVisible />)
expect(mocks.boardProps).toBeNull()
act(() => useAppStore.setState({ agentDashboardDrawerOpen: true }))
expect(mocks.boardProps).not.toBeNull()
expect(mocks.boardProps?.onOpenMap).toBeUndefined()
expect(mocks.boardProps?.initialView).toBeUndefined()
})
type RevealAgent = (args: {
repoId: string
worktreeId: string
@@ -22,16 +22,4 @@ describe('agent dashboard performance isolation', () => {
expect(nav).not.toContain('shared/dashboard-snapshot')
expect(nav).toContain("import('./AgentDashboardSidebarEntry')")
})
it('keeps map computation out of the main-renderer drawer', () => {
const board = source('components/dashboard-popout/AgentKanbanBoard.tsx')
const drawer = source('components/dashboard/AgentDashboardDrawer.tsx')
const toolbar = source('components/dashboard-popout/AgentDashboardToolbar.tsx')
expect(board).not.toContain("import('./AgentDashboardMapView')")
expect(board).not.toMatch(/from ['"].\/(?:AgentMap|useAgentMap|agent-map-)/)
expect(toolbar).not.toMatch(/from ['"].\/(?:AgentMap|useAgentMap|agent-map-)/)
expect(drawer).not.toContain("openPopout?.('map')")
expect(drawer).not.toContain('onOpenMap')
})
})
-19
View File
@@ -2732,28 +2732,9 @@
"subagents_one": "{{count}} subagent",
"subagents_other": "{{count}} subagents"
},
"map": {
"agentCount": "{{count}} agents",
"agentCount_one": "{{count}} agent",
"agentCount_other": "{{count}} agents",
"host": {
"local": "Local",
"remote": "Remote",
"ssh": "SSH",
"wsl": "WSL"
},
"liveContainmentMap": "Live containment map",
"worktreeSummary_one": "{{total}} agent · {{active}} active · {{done}} done",
"worktreeSummary_other": "{{total}} agents · {{active}} active · {{done}} done"
},
"placeholder": {
"description": "This is where all your agents will show up at a glance. The board is coming soon.",
"title": "Agent dashboard"
},
"view": {
"board": "Dashboard",
"label": "Dashboard view",
"map": "Agent Map"
}
},
"runtimeRpc": {
+1 -74
View File
@@ -17675,78 +17675,6 @@
}
},
"dashboardPopout": {
"view": {
"label": "Dashboard view",
"board": "Dashboard",
"map": "Agent Map"
},
"map": {
"host": {
"local": "Local",
"ssh": "SSH",
"wsl": "WSL",
"remote": "Remote"
},
"filters": {
"showStates": "Agent states",
"agentlessWorkspaces": "Workspaces without agents",
"orchestrationLinks": "Orchestration links",
"orchestrationLinksHidden": "Orchestration links hidden",
"workspaceVisibility": "Map content",
"title": "Map controls",
"reset": "Reset",
"ofTotalAgents": "of {{total}} agents shown",
"quickViews": "Quick views",
"agents": "Agents",
"time": "Time",
"lifespan": "Session lifespan",
"sinceMessage": "Since last message",
"timeInState": "Time in current state",
"timeMinimum": "{{label}} minimum",
"timeMaximum": "{{label}} maximum",
"timeAny": "any",
"timeRangeCount": "{{count}} ranges",
"resetRanges": "Reset ranges",
"summaryAll": "All",
"summaryCount": "{{shown}} of {{total}}",
"summarySelected": "{{count}} selected",
"workspace": "Workspace",
"stateChip": "State: {{states}}"
},
"liveContainmentMap": "Live containment map",
"empty": "No agents match the current filters.",
"canvasLabel": "Nested project, workspace, and agent map",
"zoomOut": "Zoom out",
"zoomIn": "Zoom in",
"fit": "Fit",
"openWorktree": "Open {{worktree}} worktree details",
"openFolderWorkspace": "Open {{workspace}} folder workspace details",
"worktreeSummary": "{{total}} agents · {{active}} active · {{done}} done",
"worktreeSummary_one": "{{total}} agent · {{active}} active · {{done}} done",
"worktreeSummary_other": "{{total}} agents · {{active}} active · {{done}} done",
"runningAgents": "Agents",
"spawnAgent": "Start a new agent",
"noLaunchableAgents": "No enabled agents detected.",
"sleepWorkspace": "Sleep",
"projectCount": "{{agents}} agents · {{workspaces}} workspaces",
"agentCount": "{{count}} agents",
"agentCount_one": "{{count}} agent",
"agentCount_other": "{{count}} agents",
"noWorkspaceAgents": "No agents in this workspace.",
"status": {
"doneSeen": "Done, seen"
},
"quickView": {
"everything": "Everything",
"attention": "Needs me",
"stuck": "Stuck",
"unread": "Unread",
"recent": "Last 30 min",
"longRunning": "Long runners",
"stale": "Stale > 3d",
"orchestration": "Orchestration"
}
},
"placeholder": {
"title": "Agent dashboard",
"description": "This is where all your agents will show up at a glance. The board is coming soon."
@@ -17809,8 +17737,7 @@
"project": "Project",
"workspaceStatus": "Workspace status",
"reviewStatus": "PR / MR status",
"clearAll": "Clear all filters",
"removeChip": "Remove {{filter}}"
"clearAll": "Clear all filters"
},
"search": {
"placeholder": "Search worktree, project, or agent…",
-21
View File
@@ -14769,27 +14769,6 @@
}
},
"dashboardPopout": {
"view": {
"label": "Vista del panel",
"board": "Panel",
"map": "Mapa de agentes"
},
"map": {
"host": {
"local": "Local",
"ssh": "SSH",
"wsl": "WSL",
"remote": "Remoto"
},
"liveContainmentMap": "Mapa de contención en vivo",
"empty": "Ningún agente coincide con los filtros actuales.",
"canvasLabel": "Mapa anidado de proyectos, espacios de trabajo y agentes",
"zoomOut": "Alejar",
"zoomIn": "Acercar",
"fit": "Ajustar",
"projectCount": "{{agents}} agentes · {{workspaces}} espacios de trabajo",
"agentCount": "{{count}} agentes"
},
"placeholder": {
"title": "Agent dashboard",
"description": "This is where all your agents will show up at a glance. The board is coming soon."
+1 -74
View File
@@ -16404,78 +16404,6 @@
}
},
"dashboardPopout": {
"view": {
"label": "Vue tableau de bord",
"board": "Tableau de bord",
"map": "Carte des agents"
},
"map": {
"host": {
"local": "Local",
"ssh": "SSH",
"wsl": "WSL",
"remote": "Distant"
},
"filters": {
"showStates": "États des agents",
"agentlessWorkspaces": "Espaces de travail sans agents",
"orchestrationLinks": "Liens d'orchestration",
"orchestrationLinksHidden": "Liens d'orchestration masqués",
"workspaceVisibility": "Contenu de la carte",
"title": "Contrôles de la carte",
"reset": "Réinitialiser",
"ofTotalAgents": "sur {{total}} agents affichés",
"quickViews": "Vues rapides",
"agents": "Agents",
"time": "Heure",
"lifespan": "Durée de vie de la session",
"sinceMessage": "Depuis le dernier message",
"timeInState": "Temps dans l'état actuel",
"timeMinimum": "{{label}} minimum",
"timeMaximum": "{{label}} maximum",
"timeAny": "tous",
"timeRangeCount": "{{count}} plages",
"resetRanges": "Réinitialiser les plages",
"summaryAll": "Tous",
"summaryCount": "{{shown}} sur {{total}}",
"summarySelected": "{{count}} sélectionnés",
"workspace": "Espace de travail",
"stateChip": "État : {{states}}"
},
"liveContainmentMap": "Carte d'imbrication en temps réel",
"empty": "Aucun agent ne correspond aux filtres actuels.",
"canvasLabel": "Carte imbriquée des projets, espaces de travail et agents",
"zoomOut": "Zoom arrière",
"zoomIn": "Zoom avant",
"fit": "Ajuster",
"openWorktree": "Ouvrir les détails du worktree {{worktree}}",
"openFolderWorkspace": "Ouvrir les détails de l'espace de travail de type dossier {{workspace}}",
"worktreeSummary": "{{total}} agents · {{active}} actifs · {{done}} terminés",
"worktreeSummary_one": "{{total}} agent · {{active}} actif · {{done}} terminé",
"worktreeSummary_other": "{{total}} agents · {{active}} actifs · {{done}} terminés",
"runningAgents": "Agents",
"spawnAgent": "Démarrer un nouvel agent",
"noLaunchableAgents": "Aucun agent activé détecté.",
"sleepWorkspace": "Veille",
"projectCount": "{{agents}} agents · {{workspaces}} workspaces",
"agentCount": "{{count}} agents",
"agentCount_one": "{{count}} agent",
"agentCount_other": "{{count}} agents",
"noWorkspaceAgents": "Aucun agent dans cet espace de travail.",
"status": {
"doneSeen": "Terminé, vu"
},
"quickView": {
"everything": "Tout",
"attention": "Me concerne",
"stuck": "Bloqué",
"unread": "Non lus",
"recent": "30 dernières minutes",
"longRunning": "Exécutions longues",
"stale": "Inactif > 3 j",
"orchestration": "Orchestration"
}
},
"placeholder": {
"title": "Tableau de bord des agents",
"description": "C'est ici que tous vos agents s'afficheront d'un coup d'œil. Le tableau arrive bientôt."
@@ -16537,8 +16465,7 @@
"project": "Projet",
"workspaceStatus": "Statut de l'espace de travail",
"reviewStatus": "Statut PR / MR",
"clearAll": "Effacer tous les filtres",
"removeChip": "Retirer {{filter}}"
"clearAll": "Effacer tous les filtres"
},
"search": {
"placeholder": "Rechercher un worktree, un projet ou un agent…",
-21
View File
@@ -14804,27 +14804,6 @@
}
},
"dashboardPopout": {
"view": {
"label": "ダッシュボード表示",
"board": "ダッシュボード",
"map": "Agent マップ"
},
"map": {
"host": {
"local": "ローカル",
"ssh": "SSH",
"wsl": "WSL",
"remote": "リモート"
},
"liveContainmentMap": "ライブ包含マップ",
"empty": "現在のフィルターに一致する Agent はありません。",
"canvasLabel": "プロジェクト、ワークスペース、Agent の入れ子マップ",
"zoomOut": "ズームアウト",
"zoomIn": "ズームイン",
"fit": "全体表示",
"projectCount": "{{agents}}件の Agent · {{workspaces}}件のワークスペース",
"agentCount": "{{count}}件の Agent"
},
"placeholder": {
"title": "Agent ダッシュボード",
"description": "ここに、すべての Agent が一覧で表示されます。ボードは近日公開です。"
-21
View File
@@ -14943,27 +14943,6 @@
}
},
"dashboardPopout": {
"view": {
"label": "대시보드 보기",
"board": "대시보드",
"map": "에이전트 맵"
},
"map": {
"host": {
"local": "로컬",
"ssh": "SSH",
"wsl": "WSL",
"remote": "원격"
},
"liveContainmentMap": "실시간 포함 관계 맵",
"empty": "현재 필터와 일치하는 에이전트가 없습니다.",
"canvasLabel": "프로젝트, 작업 공간, 에이전트의 중첩 맵",
"zoomOut": "축소",
"zoomIn": "확대",
"fit": "맞춤",
"projectCount": "에이전트 {{agents}}개 · 작업 공간 {{workspaces}}개",
"agentCount": "에이전트 {{count}}개"
},
"placeholder": {
"title": "에이전트 대시보드",
"description": "모든 에이전트를 한눈에 볼 수 있는 곳입니다. 보드는 곧 제공됩니다."
-21
View File
@@ -14908,27 +14908,6 @@
}
},
"dashboardPopout": {
"view": {
"label": "仪表盘视图",
"board": "仪表盘",
"map": "智能体地图"
},
"map": {
"host": {
"local": "本地",
"ssh": "SSH",
"wsl": "WSL",
"remote": "远程"
},
"liveContainmentMap": "实时包含关系图",
"empty": "没有智能体符合当前筛选条件。",
"canvasLabel": "项目、工作区和智能体的嵌套关系图",
"zoomOut": "缩小",
"zoomIn": "放大",
"fit": "适配屏幕",
"projectCount": "{{agents}} 个智能体 · {{workspaces}} 个工作区",
"agentCount": "{{count}} 个智能体"
},
"placeholder": {
"title": "智能体仪表盘",
"description": "你的所有智能体都会在这里一览无余。看板即将上线。"
@@ -1,13 +1,11 @@
import {
AGENT_STATUS_STORE_LIMITS,
AGENT_STATUS_STORE_SNAPSHOT_VERSION
AGENT_STATUS_STORE_SNAPSHOT_VERSION,
type AgentStatusFactRecord,
type AgentStatusTombstoneRecord
} from './agent-status-store-contract'
import type { AgentChildWorkAliasRecord } from './agent-status-child-work-alias'
import type { AgentChildWorkRecord } from './agent-status-child-work'
import type {
AgentStatusFactRecord,
AgentStatusTombstoneRecord
} from './agent-status-store-contract'
import type { AgentStatusParentRecord } from './agent-status-store-parent'
import type { AgentStatusStoreState } from './agent-status-store-state'
import { getUtf8ByteLength } from './utf8-byte-limits'

Some files were not shown because too many files have changed in this diff Show More