diff --git a/config/oxlint-dead-classes.json b/config/oxlint-dead-classes.json index ad58853134f..cd1c91fbb03 100644 --- a/config/oxlint-dead-classes.json +++ b/config/oxlint-dead-classes.json @@ -30,7 +30,6 @@ "error", { "allow": [ - "agent-map-*", "comment-md-*", "compact-agent-*", "feature-wall-*", diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 46ea33ce679..722ff71be5c 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -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, diff --git a/src/main/ipc/dashboard-popout.test.ts b/src/main/ipc/dashboard-popout.test.ts index 9bead362816..ae1f414806f 100644 --- a/src/main/ipc/dashboard-popout.test.ts +++ b/src/main/ipc/dashboard-popout.test.ts @@ -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', () => { diff --git a/src/main/ipc/dashboard-popout.ts b/src/main/ipc/dashboard-popout.ts index 9e71d9ac32a..af19285a67e 100644 --- a/src/main/ipc/dashboard-popout.ts +++ b/src/main/ipc/dashboard-popout.ts @@ -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() }) }) diff --git a/src/main/window/dashboard-popout-window.test.ts b/src/main/window/dashboard-popout-window.test.ts index 0e64d573f76..960379a797d 100644 --- a/src/main/window/dashboard-popout-window.test.ts +++ b/src/main/window/dashboard-popout-window.test.ts @@ -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 } diff --git a/src/main/window/dashboard-popout-window.ts b/src/main/window/dashboard-popout-window.ts index 5e765c7e432..a6b082ac1ae 100644 --- a/src/main/window/dashboard-popout-window.ts +++ b/src/main/window/dashboard-popout-window.ts @@ -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 } diff --git a/src/preload/api/dashboard-api.ts b/src/preload/api/dashboard-api.ts index aa8d6dabd8a..f3b766ce054 100644 --- a/src/preload/api/dashboard-api.ts +++ b/src/preload/api/dashboard-api.ts @@ -10,7 +10,7 @@ import type { } from '../../shared/terminal-preview' export type DashboardApi = { - openPopout: (view?: 'board' | 'map') => Promise + openPopout: () => Promise publishSnapshot: (snapshot: DashboardSnapshot) => Promise getPopoutOpen: () => Promise onPopoutOpenChanged: (callback: (open: boolean) => void) => () => void @@ -21,7 +21,6 @@ export type DashboardApi = { onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void) => () => void requestSnapshot: () => Promise onSnapshot: (callback: (snapshot: DashboardSnapshot) => void) => () => void - onViewRequested: (callback: (view: 'board' | 'map') => void) => () => void revealAgent: (args: DashboardRevealAgentArgs) => Promise ackAgent: (paneKey: string) => Promise spawnAgent: (args: DashboardSpawnAgentArgs) => Promise diff --git a/src/preload/api/dashboard-bridge.ts b/src/preload/api/dashboard-bridge.ts index e6862504da9..b1e4690cbd7 100644 --- a/src/preload/api/dashboard-bridge.ts +++ b/src/preload/api/dashboard-bridge.ts @@ -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 => - ipcRenderer.invoke('dashboardPopout:open', view), + openPopout: (): Promise => ipcRenderer.invoke('dashboardPopout:open'), // ── Producer side (main window) ────────────────────────────────────── publishSnapshot: (snapshot: DashboardSnapshot): Promise => @@ -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 => ipcRenderer.invoke('dashboardPopout:revealAgent', args), ackAgent: (paneKey: string): Promise => diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 693e7572ddf..6987df890a7 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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); diff --git a/src/renderer/src/components/AgentQuestionIcon.tsx b/src/renderer/src/components/AgentQuestionIcon.tsx index 6f2c3975a92..843ba913229 100644 --- a/src/renderer/src/components/AgentQuestionIcon.tsx +++ b/src/renderer/src/components/AgentQuestionIcon.tsx @@ -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 diff --git a/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx b/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx deleted file mode 100644 index 0f861279438..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx +++ /dev/null @@ -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 - 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 ( - <> - - } - /> - 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) - }} - /> -
- - - - {dialogCard ? ( - - ) : null} -
- - ) -} diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx deleted file mode 100644 index 9db95d8cad7..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx +++ /dev/null @@ -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('[data-agent-unread-marker]')! - const agentMark = - marker.parentElement!.querySelector('.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( - - (['ssh'])} - onOpenTerminal={vi.fn()} - /> - - ) - - 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( - - ) - 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('.agent-map-canvas > svg')! - const selectedNode = (): SVGGElement => - view.container.querySelector('.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( - - ) - - 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('.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(['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(['attention', 'working', 'done', 'idle']) - const view = renderMap([agent], { enabledStates: all }) - fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })) - const viewBox = view.container - .querySelector('.agent-map-canvas > svg')! - .getAttribute('viewBox') - - const onOpenTerminal = vi.fn() - view.rerender( - (['done'])} - /> - ) - expect(view.container.querySelector('.agent-map-canvas > svg')).not.toBeInTheDocument() - view.rerender( - - ) - - 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('.agent-map-canvas > svg')! - .getAttribute('viewBox') - - view.rerender() - expect(view.container.querySelector('.agent-map-canvas > svg')).not.toBeInTheDocument() - view.rerender() - - 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( - ({ ...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( - ({ ...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) - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.tsx deleted file mode 100644 index 7b7afc56bc7..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMap.tsx +++ /dev/null @@ -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 - 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 - enabledHosts?: ReadonlySet - /** Owned by the board's filter menu. Defaults to shown. */ - showOrchestrationLinks?: boolean - launchableAgentsByWorktreeId?: Record - workspaceContextMenusEnabled?: boolean - onWorkspaceContextMenuOpenChange?: (open: boolean) => void - onOpenTerminal: (card: DashboardCard) => void - onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void - onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void -} - -const ALL_AGENT_STATES: ReadonlySet = new Set([ - 'attention', - 'working', - 'done', - 'idle' -]) -const ALL_HOSTS: ReadonlySet = 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(null) - const layoutCacheRef = useRef(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 ( -
-
- -
-
- ) -} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx deleted file mode 100644 index 7ec41a3d8b4..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx +++ /dev/null @@ -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 - } -})) - -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() - const svg = container.querySelector('.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() - const svg = container.querySelector('.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) - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx deleted file mode 100644 index 84290f36f40..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx +++ /dev/null @@ -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 - selectedPaneKey: string | null - allowAggregation: boolean - showOrchestrationLinks: boolean - recentFlareStatuses: ReadonlyMap - launchableAgentsByWorktreeId?: Record - workspaceContextMenusEnabled?: boolean - onWorkspaceContextMenuOpenChange?: (open: boolean) => void - onSelectAgent: (card: DashboardCard) => void - onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void - onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void -} - -export const AgentMapCanvas = forwardRef( - function AgentMapCanvas( - { - layout, - repoIconsByRepoId, - selectedPaneKey, - allowAggregation, - showOrchestrationLinks, - recentFlareStatuses, - launchableAgentsByWorktreeId, - workspaceContextMenusEnabled = false, - onWorkspaceContextMenuOpenChange, - onSelectAgent, - onSpawnAgent, - onSleepWorkspace - }, - forwardedRef - ): React.JSX.Element { - const containerRef = useRef(null) - const svgRef = useRef(null) - const nodeRefs = useRef(new Map()) - const dragRef = useRef<{ - pointerId: number - point: AgentMapViewport['center'] - center: AgentMapViewport['center'] - worldPerPixelX: number - worldPerPixelY: number - } | null>(null) - const viewportFrameRef = useRef(null) - const pendingViewportRef = useRef(null) - const interactionBoundsRef = useRef(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({ - 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, 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 ( -
- {motionLayout.projects.length === 0 ? ( -
- {translate('dashboardPopout.map.empty', 'No agents match the current filters.')} -
- ) : ( - { - 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() - } - }} - > - - - )} - - zoomAt(viewportRef.current.zoom * 1.25)} - onZoomOut={() => zoomAt(viewportRef.current.zoom / 1.25)} - /> - {contextMenus} -
- ) - } -) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapContentFilterItems.tsx b/src/renderer/src/components/dashboard-popout/AgentMapContentFilterItems.tsx deleted file mode 100644 index 25a2a94020c..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapContentFilterItems.tsx +++ /dev/null @@ -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 ( - <> - - {translate('dashboardPopout.map.filters.workspaceVisibility', 'Map content')} - - onShowAgentlessWorkspacesChange(checked === true)} - onSelect={(event) => event.preventDefault()} - > - - {translate( - 'dashboardPopout.map.filters.agentlessWorkspaces', - 'Workspaces without agents' - )} - - - - onShowOrchestrationLinksChange(checked === true)} - onSelect={(event) => event.preventDefault()} - > - - {translate('dashboardPopout.map.filters.orchestrationLinks', 'Orchestration links')} - - - - - ) -} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapFilterCheckbox.tsx b/src/renderer/src/components/dashboard-popout/AgentMapFilterCheckbox.tsx deleted file mode 100644 index dca4532eeba..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapFilterCheckbox.tsx +++ /dev/null @@ -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 ( - - ) -} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapFilterChips.tsx b/src/renderer/src/components/dashboard-popout/AgentMapFilterChips.tsx deleted file mode 100644 index ab41cc3151e..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapFilterChips.tsx +++ /dev/null @@ -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 = (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 ( -
- {chips.map((chip) => ( - - {chip.label} - - - ))} - -
- ) -} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapFilterPanel.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapFilterPanel.test.tsx deleted file mode 100644 index 40fa1dfbdf9..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapFilterPanel.test.tsx +++ /dev/null @@ -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( - - ) - - fireEvent.click(screen.getByRole('button', { name: /^Filter/ })) - - expect(screen.getByRole('button', { name: /Agents/ })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /Hosts/ })).not.toBeInTheDocument() - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapFilterPanel.tsx b/src/renderer/src/components/dashboard-popout/AgentMapFilterPanel.tsx deleted file mode 100644 index 7e717eb3956..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapFilterPanel.tsx +++ /dev/null @@ -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>(() => new Set(['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(['quick'])) - } - const applyQuickView = (id: Parameters[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 ( - - - - - -
- - {translate('dashboardPopout.map.filters.title', 'Map controls')} - - - - {shownCount}{' '} - - {translate('dashboardPopout.map.filters.ofTotalAgents', 'of {{total}} agents shown', { - total: cards.length - })} - - -
- -
- toggleSection('quick', next)} - > -
- {AGENT_MAP_QUICK_VIEWS.map((view) => ( - - ))} -
-
- - toggleSection('state', next)} - > - {AGENT_STATE_ROWS.map(({ state, dotState }) => ( - map.toggleState(state)} - leading={} - /> - ))} - - - {agentTypes.length > 1 ? ( - id)} - open={open.has('agent')} - onOpenChange={(next) => toggleSection('agent', next)} - > - {agentTypes.map((agentType) => ( - map.toggleAgentType(agentType)} - /> - ))} - - ) : null} - - toggleSection('time', next)} - > - {AGENT_MAP_TIME_FIELDS.map((field: AgentMapTimeField) => ( - map.setTimeRange(field, range)} - /> - ))} - - - - toggleSection('workspace', next)} - > - {projects.map((option) => ( - - onFiltersChange({ - ...filters, - projects: toggleDashboardFilter(filters.projects, option.id) - }) - } - /> - ))} - - - toggleSection('content', next)} - > - {statuses.map((option) => { - const meta = getWorkspaceStatusVisualMeta({ - id: option.id, - label: option.label, - color: option.color - }) - return ( - - onFiltersChange({ - ...filters, - workspaceStatuses: toggleDashboardFilter(filters.workspaceStatuses, option.id) - }) - } - leading={} - /> - ) - })} -
- {REVIEW_OPTIONS.map((option: DashboardReviewFilter) => ( - - onFiltersChange({ - ...filters, - reviewStates: toggleDashboardFilter(filters.reviewStates, option) - }) - } - /> - ))} -
- onShowAgentlessWorkspacesChange(!showAgentlessWorkspaces)} - /> - onShowOrchestrationLinksChange(!showOrchestrationLinks)} - /> - - - {activeCount > 0 ? ( - - ) : null} -
- - - ) -} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapFilterSection.tsx b/src/renderer/src/components/dashboard-popout/AgentMapFilterSection.tsx deleted file mode 100644 index ae06227d0a3..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapFilterSection.tsx +++ /dev/null @@ -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 ( -
- - {expanded ?
{children}
: null} -
- ) -} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapMotion.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapMotion.test.tsx deleted file mode 100644 index 95b019b184f..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapMotion.test.tsx +++ /dev/null @@ -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 { - 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() - - 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() - - vi.useFakeTimers() - view.rerender() - 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() - const exiting = view.container.querySelector('[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() - - vi.useFakeTimers() - view.rerender() - 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() - const ring = view.container.querySelector( - '[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() - - vi.useFakeTimers() - view.rerender() - await act(async () => { - vi.advanceTimersByTime(AGENT_MAP_EXIT_DURATION_MS - 10) - }) - view.rerender() - 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( - (commitCount += 1)}> - - - ) - commitCount = 0 - - view.rerender( - (commitCount += 1)}> - - - ) - - 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() - - vi.useFakeTimers() - view.rerender() - 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') - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenu.tsx b/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenu.tsx deleted file mode 100644 index ba653e7cd59..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenu.tsx +++ /dev/null @@ -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(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 ( -
- - - - - - {label} - { - openModal( - 'new-workspace-composer', - target.kind === 'repo' - ? { initialRepoId: target.repo.id, telemetrySource: 'sidebar' } - : { initialProjectGroupId: target.group.id, telemetrySource: 'sidebar' } - ) - }} - > - - {createState.tooltip} - - - -
- ) -} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenuLoader.tsx b/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenuLoader.tsx deleted file mode 100644 index ff81fdaca4f..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenuLoader.tsx +++ /dev/null @@ -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 ( - - - - ) -} - -export type { AgentMapProjectContextMenuRequest } diff --git a/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx deleted file mode 100644 index 56039ad4db7..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx +++ /dev/null @@ -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( - - - - ) - - 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( - - - - - - ) - - 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') - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapQuestionMarker.tsx b/src/renderer/src/components/dashboard-popout/AgentMapQuestionMarker.tsx deleted file mode 100644 index 00447f63d5e..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapQuestionMarker.tsx +++ /dev/null @@ -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 ( - - ) -} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx deleted file mode 100644 index ccc3b29e89f..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx +++ /dev/null @@ -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 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('.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('.agent-map-worktree-ring')! - - fireEvent.pointerOver(group) - ring.focus() - ring.blur() - - expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument() - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx deleted file mode 100644 index a4914bbdad5..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx +++ /dev/null @@ -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 - 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 - launchableAgentsByWorktreeId?: Record - nodeRefs: MutableRefObject> - onSelectAgent: (card: DashboardCard) => void - onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void - onOpenProjectContextMenu?: ( - event: React.MouseEvent, - project: AgentMapProjectRing - ) => void - onOpenWorkspaceContextMenu?: ( - event: React.MouseEvent, - worktree: AgentMapWorktreeRing - ) => void - onAgentKeyDown: (event: React.KeyboardEvent, 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(null) - const [focusedWorktreeId, setFocusedWorktreeId] = useState(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() - 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() - 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 ( - - { - event.preventDefault() - event.stopPropagation() - onOpenProjectContextMenu(event, project) - } - : undefined - } - /> - - {project.worktrees.map((child) => { - const parent = child.parentId ? worktreesById.get(child.parentId) : undefined - return !parent || child.y <= parent.y ? null : ( - - ) - })} - - - {crossWorktreeLineage.map(({ parent, child }) => ( - - ))} - - {project.worktrees.map((worktree) => ( - - ))} - - {project.worktrees.map((worktree) => - worktree.id === activeWorktreeId ? null : ( - - ) - )} - - - -
- - - {project.name.toUpperCase()} - - {projectHosts.map((host) => ( - - ))} -
-
- {visibleLabels.projectCountIds.has(project.id) ? ( - - {projectCountText} - - ) : null} -
-
- ) - })} - {/* Drawn last so a hovered name clears every other project's rings, and - * outside the declutter pass so it reads at any zoom. */} - {activeWorktree ? ( - - - - ) : null} - - ) -}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx b/src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx deleted file mode 100644 index 17b992aaaff..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx +++ /dev/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(null) - useEffect(() => { - triggerRef.current?.dispatchEvent( - new MouseEvent('contextmenu', { - bubbles: true, - cancelable: true, - clientX: request.clientX, - clientY: request.clientY, - button: 2 - }) - ) - }, [request]) - - return ( -
- - - - - - {request.worktreeName} - {onSpawnAgent ? ( - - - - {translate('dashboardPopout.map.spawnAgent', 'Start a new agent')} - - - {request.launchableAgents.map((agent) => ( - onSpawnAgent({ worktreeId: request.worktreeId, agent })} - > - - {getAgentLabel(agent)} - - ))} - - - ) : null} - {onSpawnAgent && onSleepWorkspace ? : null} - {onSleepWorkspace ? ( - onSleepWorkspace({ worktreeId: request.worktreeId })}> - - {translate('dashboardPopout.map.sleepWorkspace', 'Sleep')} - - ) : null} - - -
- ) -} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx deleted file mode 100644 index 3680658539e..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx +++ /dev/null @@ -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) - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.test.tsx deleted file mode 100644 index 05ce6113726..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.test.tsx +++ /dev/null @@ -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() - return { - ...actual, - updateAgentMapLayout: ( - ...args: Parameters - ): ReturnType => { - 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() - return { - ...actual, - placeAgentMapProjects: ( - ...args: Parameters - ): ReturnType => { - 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 & { 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 { - return render( - - ) -} - -async function openTimeSection(): Promise { - 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 ( - { - setRange(next) - onChange(next) - }} - /> - ) -} - -/** Radix reads geometry off the root and gates moves on pointer capture. */ -function stubSliderGeometry(): () => void { - const captured = new Set() - 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( - - ) - - 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() - - 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 => ( - - ) - 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 => ( - - ) - 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( - - ) - 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( - - ) - - 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 }) - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.tsx b/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.tsx deleted file mode 100644 index 752a00ea673..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.tsx +++ /dev/null @@ -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(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 ( -
-
- {label} - - {isFull - ? translate('dashboardPopout.map.filters.timeAny', 'any') - : `${agentMapTimeStopLabel(displayedRange.min)} – ${agentMapTimeStopLabel(displayedRange.max)}`} - -
- { - 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 }) - }} - /> -
- {TICKS.map((tick) => ( - {agentMapTimeStopLabel(tick)} - ))} -
-
- ) -} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapViewportControls.tsx b/src/renderer/src/components/dashboard-popout/AgentMapViewportControls.tsx deleted file mode 100644 index 6e035dca692..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapViewportControls.tsx +++ /dev/null @@ -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 ( -
- - - {Math.round(zoom * 100)}% - - - -
- ) -} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts deleted file mode 100644 index 7275b3d274e..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts +++ /dev/null @@ -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['"]/ - ) - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx deleted file mode 100644 index bc4958408a2..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx +++ /dev/null @@ -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( - - {}} - /> - - ) - - 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( - - {}} - /> - - ) - - 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( - - {}} - /> - - ) - - 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( - - {}} - /> - - ) - - 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( - - {}} - /> - - ) - - 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( - - {}} - /> - - ) - - 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( - - {}} - /> - - ) - 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( - - {}} /> - - ) - - 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((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( - - {}} - /> - - ) - - 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( - - {}} /> - - ) - - 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() - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.tsx deleted file mode 100644 index 1ac20df44d2..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.tsx +++ /dev/null @@ -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>() - 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() - 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(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 -} - -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 ( -
- - - - - -
- ) -} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenuLoader.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenuLoader.tsx deleted file mode 100644 index 8ece35ad2ef..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenuLoader.tsx +++ /dev/null @@ -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 ( - - - - ) -} - -export type { AgentMapWorkspaceContextMenuRequest } diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx deleted file mode 100644 index 27a1c455341..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx +++ /dev/null @@ -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 ( - - - {worktree.name} - - - {agentCountText} - - - ) -}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx deleted file mode 100644 index 55a2d5f547e..00000000000 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx +++ /dev/null @@ -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 - launchableAgents?: readonly TuiAgent[] - nodeRefs: MutableRefObject> - onSelectAgent: (card: DashboardCard) => void - onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void - onOpenWorkspaceContextMenu?: ( - event: React.MouseEvent, - worktree: AgentMapWorktreeRing - ) => void - onLabelHoverChange: (worktreeId: string, active: boolean) => void - onLabelFocusChange: (worktreeId: string, active: boolean) => void - onAgentKeyDown: (event: React.KeyboardEvent, 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 ( - -
- {project.name} - {worktree.name} - - {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 - } - )} - -
-
-

- {translate('dashboardPopout.map.runningAgents', 'Agents')} -

-
- {worktree.agents.length === 0 ? ( -

- {translate('dashboardPopout.map.noWorkspaceAgents', 'No agents in this workspace.')} -

- ) : ( - worktree.agents.map((agent) => ( - - )) - )} -
-
- {onSpawnAgent ? ( -
-

- {translate('dashboardPopout.map.spawnAgent', 'Start a new agent')} -

- {launchableAgents && launchableAgents.length > 0 ? ( -
- {launchableAgents.map((agent) => ( - - ))} -
- ) : ( -

- {translate('dashboardPopout.map.noLaunchableAgents', 'No enabled agents detected.')} -

- )} -
- ) : null} -
- ) -} - -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 ( - - 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 ? ( - - setDetailsOpen(false)} - /> - - ) -}) diff --git a/src/renderer/src/components/dashboard-popout/agent-dashboard-filter-options.ts b/src/renderer/src/components/dashboard-popout/agent-dashboard-filter-options.ts index db9b42c57eb..feb4661e23a 100644 --- a/src/renderer/src/components/dashboard-popout/agent-dashboard-filter-options.ts +++ b/src/renderer/src/components/dashboard-popout/agent-dashboard-filter-options.ts @@ -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 diff --git a/src/renderer/src/components/dashboard-popout/agent-map-agent-placement.ts b/src/renderer/src/components/dashboard-popout/agent-map-agent-placement.ts deleted file mode 100644 index 3eb4fc329f3..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-agent-placement.ts +++ /dev/null @@ -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) - } - }) -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-canvas-zoom.ts b/src/renderer/src/components/dashboard-popout/agent-map-canvas-zoom.ts deleted file mode 100644 index ab2d846a387..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-canvas-zoom.ts +++ /dev/null @@ -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 - ) -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-filter-labels.ts b/src/renderer/src/components/dashboard-popout/agent-map-filter-labels.ts deleted file mode 100644 index 4fd91c81ce9..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-filter-labels.ts +++ /dev/null @@ -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') - } -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-filter-summaries.ts b/src/renderer/src/components/dashboard-popout/agent-map-filter-summaries.ts deleted file mode 100644 index bb49665d74d..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-filter-summaries.ts +++ /dev/null @@ -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( - selected: ReadonlySet, - 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 - } -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts deleted file mode 100644 index 2fb54c2f45e..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts +++ /dev/null @@ -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 { - 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 - }) - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-filter.ts b/src/renderer/src/components/dashboard-popout/agent-map-filter.ts deleted file mode 100644 index 698bd64b49c..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-filter.ts +++ /dev/null @@ -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 - -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 { - const present = new Set(cards.map((card) => card.paneKey)) - const flows = new Set() - 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 - enabledHosts: ReadonlySet - enabledAgentTypes?: ReadonlySet - 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 { - const counts = new Map() - 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))) -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts deleted file mode 100644 index 150c228b84f..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts +++ /dev/null @@ -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(/ { - 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(' { - 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(/ { - const map = source('AgentMap.tsx') - const scene = source('AgentMapScene.tsx') - - expect(map).toMatch(/selectAgentMapRecentFlareStatuses\(visibleCards\)/) - expect(scene).not.toContain('selectAgentMapRecentFlareStatuses') - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts deleted file mode 100644 index ea5bbab362e..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts +++ /dev/null @@ -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/) - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts deleted file mode 100644 index f0f2d458be0..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts +++ /dev/null @@ -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 { - return { ...emptyAgentMapStatusCounts(), ...overrides } -} - -function worktree(overrides: Partial = {}): 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 = {} -): 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) - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts deleted file mode 100644 index 67081f39084..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts +++ /dev/null @@ -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> - -export type AgentMapVisibleLabels = { - /** Worktree ring ids whose name can render without colliding. */ - worktreeIds: Set - /** 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 -} - -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 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() - 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() - 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 } -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts deleted file mode 100644 index 93138ba9dce..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts +++ /dev/null @@ -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 } -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts deleted file mode 100644 index 3304c45537f..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts +++ /dev/null @@ -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() - return { - ...actual, - packAgentMapWorktrees: (...args: Parameters) => { - packWorktrees() - return actual.packAgentMapWorktrees(...args) - } - } -}) - -const NOW = 2_000_000_000 - -function card(overrides: Partial = {}): 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 { - 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) - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout.ts deleted file mode 100644 index 941b4613f35..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-layout.ts +++ /dev/null @@ -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 - 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 & { x: number; y: number } -type LocalProject = Omit & { - 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, - now: number -): LocalProject { - const byWorktree = new Map() - 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() - 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 } -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts deleted file mode 100644 index 5b081e5dbc8..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.ts b/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.ts deleted file mode 100644 index 2a87f301e08..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.ts +++ /dev/null @@ -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() - -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 -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-lineage-layout.ts b/src/renderer/src/components/dashboard-popout/agent-map-lineage-layout.ts deleted file mode 100644 index f5d3665c483..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-lineage-layout.ts +++ /dev/null @@ -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 -): 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, - nodeRadius: number, - emitted: Set -): 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 - ): 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, - childPaneKeys: ReadonlySet, - nodeRadius: number -): { agents: AgentMapLineagePosition[]; radius: number } { - const levels: DashboardCard[][] = [] - const emitted = new Set() - 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() - const childPaneKeys = new Set() - - 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() - 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) - ) - } -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts deleted file mode 100644 index d8c4baf6edc..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-navigation.ts b/src/renderer/src/components/dashboard-popout/agent-map-navigation.ts deleted file mode 100644 index ae3cfdbea2b..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-navigation.ts +++ /dev/null @@ -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 -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.test.ts deleted file mode 100644 index c7234cc6223..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.test.ts +++ /dev/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 { - 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) - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts b/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts deleted file mode 100644 index 34db8ccfc31..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts +++ /dev/null @@ -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 - -/** 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 { - 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 - -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'] -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-node-presentation.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-node-presentation.test.ts deleted file mode 100644 index f02837497cb..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-node-presentation.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-node-presentation.ts b/src/renderer/src/components/dashboard-popout/agent-map-node-presentation.ts deleted file mode 100644 index 78910f64865..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-node-presentation.ts +++ /dev/null @@ -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) -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-packing-spatial-index.ts b/src/renderer/src/components/dashboard-popout/agent-map-packing-spatial-index.ts deleted file mode 100644 index 83aefde2f06..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-packing-spatial-index.ts +++ /dev/null @@ -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> - cellSize: number -} - -export type AgentMapPackingSpatialIndex = Map - -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, - index: AgentMapPackingSpatialIndex -): boolean { - const searchRadius = candidate.radius + AGENT_MAP_WORKTREE_GAP - const checked = new Set() - 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 -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-project-placement.ts b/src/renderer/src/components/dashboard-popout/agent-map-project-placement.ts deleted file mode 100644 index 413102bb3f0..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-project-placement.ts +++ /dev/null @@ -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(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( - 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 - } -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-quick-views.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-quick-views.test.ts deleted file mode 100644 index 0d83b3eeed4..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-quick-views.test.ts +++ /dev/null @@ -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 { - 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[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([]) - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-quick-views.ts b/src/renderer/src/components/dashboard-popout/agent-map-quick-views.ts deleted file mode 100644 index e635687fafa..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-quick-views.ts +++ /dev/null @@ -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 - hosts: ReadonlySet - agentTypes: ReadonlySet - 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(['attention', 'done']) }) - }, - { - id: 'stuck', - label: () => translate('dashboardPopout.map.quickView.stuck', 'Stuck'), - apply: (base) => ({ - ...base, - states: new Set(['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(['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 -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx deleted file mode 100644 index e6b84a0a20f..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx +++ /dev/null @@ -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 { - 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 - enabledHosts?: ReadonlySet - showOrchestrationLinks?: boolean - launchableAgentsByWorktreeId?: Record - 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 { - return render( - , - { wrapper: TooltipProvider } - ) -} - -export type AgentMapTestEnvironment = { - /** Exposed so tests can assert the canvas does not re-read layout when idle. */ - boundsSpy: ReturnType -} - -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 -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-spawn-clustering.ts b/src/renderer/src/components/dashboard-popout/agent-map-spawn-clustering.ts deleted file mode 100644 index a341fdd1767..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-spawn-clustering.ts +++ /dev/null @@ -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, - containerIdentity: (card: DashboardCard) => string -): string | undefined { - const ownContainerId = cards[0] ? containerIdentity(cards[0]) : undefined - const linkCounts = new Map() - 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] -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-time-filter.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-time-filter.test.ts deleted file mode 100644 index b485d308efd..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-time-filter.test.ts +++ /dev/null @@ -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 { - 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('∞') - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-time-filter.ts b/src/renderer/src/components/dashboard-popout/agent-map-time-filter.ts deleted file mode 100644 index 86478c93ebd..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-time-filter.ts +++ /dev/null @@ -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 - -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 { - 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])) -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-viewport-transition.ts b/src/renderer/src/components/dashboard-popout/agent-map-viewport-transition.ts deleted file mode 100644 index 6fe03f6f585..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-viewport-transition.ts +++ /dev/null @@ -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) - } - } -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-workspace-identity.ts b/src/renderer/src/components/dashboard-popout/agent-map-workspace-identity.ts deleted file mode 100644 index 5ceaf4e93eb..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-workspace-identity.ts +++ /dev/null @@ -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) -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts deleted file mode 100644 index 1a9593af373..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts +++ /dev/null @@ -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 { - 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 { - 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']) - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.ts b/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.ts deleted file mode 100644 index 311dd95e2f6..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.ts +++ /dev/null @@ -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) - ) - ) -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts deleted file mode 100644 index 1ecbdb0beb1..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts +++ /dev/null @@ -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 { - 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') - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.ts deleted file mode 100644 index 853a41b38a9..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.ts +++ /dev/null @@ -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 -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-host.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-host.ts deleted file mode 100644 index 247b3f5b08c..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-worktree-host.ts +++ /dev/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 - } -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts deleted file mode 100644 index 3025aecad77..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts +++ /dev/null @@ -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 = [] - 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) { - const set = Map.prototype.set - let numericMapSets = 0 - Map.prototype.set = function (this: Map, 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) - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.ts deleted file mode 100644 index 1a6e2875b65..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.ts +++ /dev/null @@ -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 = { - 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(id: string, worktrees: T[]): WorktreeFamily { - 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( - root: T, - childrenByParent: ReadonlyMap, - emitted: Set, - ancestors: ReadonlySet -): WorktreeFamily { - 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( - root: T, - childrenByParent: ReadonlyMap, - emitted: ReadonlySet -): T[] | null { - const worktrees: T[] = [] - const ancestors = new Set() - 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(worktrees: T[]): WorktreeFamily { - 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( - root: T, - childrenByParent: ReadonlyMap, - emitted: Set -): WorktreeFamily { - 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( - sorted: T[], - childrenByParent: ReadonlyMap, - childIds: ReadonlySet -): T[] { - const levels: T[][] = [] - const emitted = new Set() - 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(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() - 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() - const childIds = new Set() - 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() - const families: WorktreeFamily[] = [] - 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)) -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts deleted file mode 100644 index d074c30b2ad..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts +++ /dev/null @@ -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 - 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, 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 - } - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.ts deleted file mode 100644 index 4dab5ac2346..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.ts +++ /dev/null @@ -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, - 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(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)) -} diff --git a/src/renderer/src/components/dashboard-popout/agent-map.css b/src/renderer/src/components/dashboard-popout/agent-map.css deleted file mode 100644 index 189540305a7..00000000000 --- a/src/renderer/src/components/dashboard-popout/agent-map.css +++ /dev/null @@ -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; - } -} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts b/src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts deleted file mode 100644 index c44843802f8..00000000000 --- a/src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useEffect, useState, type RefObject } from 'react' - -export type AgentMapCanvasSize = { width: number; height: number } - -export function useAgentMapCanvasSize( - containerRef: RefObject, - onResize: () => void -): AgentMapCanvasSize { - const [size, setSize] = useState({ 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 -} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx b/src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx deleted file mode 100644 index 198885bdf67..00000000000 --- a/src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx +++ /dev/null @@ -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 - 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, - project: AgentMapProjectRing - ) => void - onOpenWorkspaceContextMenu?: ( - event: React.MouseEvent, - worktree: AgentMapWorktreeRing - ) => void -} { - const requestIdRef = useRef(0) - const [workspaceRequest, setWorkspaceRequest] = - useState(null) - const [projectRequest, setProjectRequest] = useState( - null - ) - const [snapshotRequest, setSnapshotRequest] = - useState(null) - const snapshotMenuEnabled = - !enabled && (onSpawnAgent !== undefined || onSleepWorkspace !== undefined) - const openSnapshotWorkspaceMenu = useCallback( - (event: React.MouseEvent, 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, 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, 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 ? ( - - ) : null - ) : enabled ? ( - <> - {workspaceRequest ? ( - - ) : null} - {projectRequest ? ( - - ) : null} - - ) : null - - return { - contextMenus, - onOpenProjectContextMenu: enabled ? openProjectContextMenu : undefined, - onOpenWorkspaceContextMenu: enabled - ? openWorkspaceContextMenu - : snapshotMenuEnabled - ? openSnapshotWorkspaceMenu - : undefined - } -} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapFilters.test.tsx b/src/renderer/src/components/dashboard-popout/useAgentMapFilters.test.tsx deleted file mode 100644 index 1cdddd5570a..00000000000 --- a/src/renderer/src/components/dashboard-popout/useAgentMapFilters.test.tsx +++ /dev/null @@ -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]) - }) - }) -}) diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapFilters.ts b/src/renderer/src/components/dashboard-popout/useAgentMapFilters.ts deleted file mode 100644 index 330a05947e9..00000000000 --- a/src/renderer/src/components/dashboard-popout/useAgentMapFilters.ts +++ /dev/null @@ -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 - -function toggle(current: ReadonlySet, value: T): Set { - 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(() => - mapFacets(emptyAgentMapFilterState(agentTypes)) - ) - const [mutedAgentTypes, setMutedAgentTypes] = useState>(() => new Set()) - const enabledAgentTypes = useMemo( - () => new Set(agentTypes.filter((agentType) => !mutedAgentTypes.has(agentType))), - [agentTypes, mutedAgentTypes] - ) - - const patch = useCallback( - (next: Partial) => 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(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]) - } -} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapMotionLayout.ts b/src/renderer/src/components/dashboard-popout/useAgentMapMotionLayout.ts deleted file mode 100644 index 6694c95cf51..00000000000 --- a/src/renderer/src/components/dashboard-popout/useAgentMapMotionLayout.ts +++ /dev/null @@ -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 { - return new Set( - layout.projects.flatMap((project) => - project.worktrees.flatMap((worktree) => worktree.agents.map((agent) => agent.card.paneKey)) - ) - ) -} - -function allWorktreeIds(layout: AgentMapLayout): Set { - return new Set( - layout.projects.flatMap((project) => project.worktrees.map((worktree) => worktree.id)) - ) -} - -function retainMotionState( - previous: T | undefined, - next: T -): T { - return { - ...next, - motionState: !previous - ? 'entering' - : previous.motionState === 'entering' - ? 'entering' - : undefined - } -} - -function reconcileAgents( - previous: AgentMapWorktreeRing, - next: AgentMapWorktreeRing, - nextAgentIds: ReadonlySet -): 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, - nextWorktreeIds: ReadonlySet -): 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 | null>(null) - const exitTimerRef = useRef | 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 -} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapPointerHold.ts b/src/renderer/src/components/dashboard-popout/useAgentMapPointerHold.ts deleted file mode 100644 index 924c18d6586..00000000000 --- a/src/renderer/src/components/dashboard-popout/useAgentMapPointerHold.ts +++ /dev/null @@ -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 `` 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(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 } -} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapSelectedFocus.ts b/src/renderer/src/components/dashboard-popout/useAgentMapSelectedFocus.ts deleted file mode 100644 index 2f22f63747e..00000000000 --- a/src/renderer/src/components/dashboard-popout/useAgentMapSelectedFocus.ts +++ /dev/null @@ -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 - ]) -} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapViewportTransition.ts b/src/renderer/src/components/dashboard-popout/useAgentMapViewportTransition.ts deleted file mode 100644 index 91a8e6e1af1..00000000000 --- a/src/renderer/src/components/dashboard-popout/useAgentMapViewportTransition.ts +++ /dev/null @@ -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 } -} diff --git a/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx b/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx index 97c31c4747a..371281b167f 100644 --- a/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx @@ -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() - 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 diff --git a/src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts b/src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts index 3232cacfa4f..5bb1f88b240 100644 --- a/src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts +++ b/src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts @@ -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') - }) }) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 41eedcf389a..f9c1d6e80d5 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9be44123070..f452788ac95 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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…", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2a47a73502d..b4d425180b8 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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." diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 3a56fce87a6..71183387390 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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…", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 696135a7669..ba36240b2f1 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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 が一覧で表示されます。ボードは近日公開です。" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5fd1e6addb8..9f9f38bb565 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": "모든 에이전트를 한눈에 볼 수 있는 곳입니다. 보드는 곧 제공됩니다." diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 125876be1a0..f44bc5972e1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": "你的所有智能体都会在这里一览无余。看板即将上线。" diff --git a/src/shared/agent-status-store-snapshot-budget.ts b/src/shared/agent-status-store-snapshot-budget.ts index dfb4665a01a..e8a40971d50 100644 --- a/src/shared/agent-status-store-snapshot-budget.ts +++ b/src/shared/agent-status-store-snapshot-budget.ts @@ -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' diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts index a4026e2eeac..bfd41984f81 100644 --- a/src/shared/dashboard-snapshot.ts +++ b/src/shared/dashboard-snapshot.ts @@ -103,11 +103,11 @@ export type DashboardCard = { leafId: string | null /** Agent pane that spawned this agent, when both are visible. */ parentPaneKey?: string - /** Direct workspace parent. The map uses it only when both workspace rings are visible. */ + /** Direct workspace parent. */ parentWorktreeId?: string repoName: string worktreeName: string - /** Optional for preload compatibility with snapshots produced before Agent Map. */ + /** Optional for preload compatibility with snapshots produced by older hosts. */ hostKind?: DashboardCardHostKind /** Exact owner used by in-window workspace actions when IDs collide across hosts. */ executionHostId?: ExecutionHostId diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts index e71cc8def32..23657cf0fbe 100644 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -59,8 +59,6 @@ const INVENTORY: readonly InventoryGroup[] = [ ['src/renderer/src/components/automations/AutomationListLocalRow.tsx', 2], 'src/renderer/src/components/automations/automation-draft-model.ts', ['src/renderer/src/components/automations/automation-list-search-rows.ts', 2], - ['src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx', 2], - ['src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx', 2], ['src/renderer/src/components/settings/NativeChatSupportedAgents.tsx', 2], ['src/renderer/src/components/settings/QuickCommandsList.tsx', 2], ['src/renderer/src/components/tab-bar/TabBarQuickCommandItem.tsx', 2],