From 1b6481d692e90abbf540df2ff9b9f61ac17ecfc2 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:35:21 -0700 Subject: [PATCH] Remove agent map from dashboard popout The agent map view was not functional and its components have been removed entirely. The dashboard popout now only supports the kanban board view, with all map-related code, utilities, types, and translations cleaned up accordingly. --- config/oxlint-dead-classes.json | 1 - config/scripts/ci-shard-timings.json | 26 - src/main/ipc/dashboard-popout.test.ts | 11 +- src/main/ipc/dashboard-popout.ts | 7 +- .../window/dashboard-popout-window.test.ts | 34 +- src/main/window/dashboard-popout-window.ts | 18 +- src/preload/api/dashboard-api.ts | 3 +- src/preload/api/dashboard-bridge.ts | 9 +- src/renderer/src/assets/main.css | 4 +- .../src/components/AgentQuestionIcon.tsx | 5 +- .../AgentDashboardMapView.tsx | 188 ----- .../dashboard-popout/AgentMap.test.tsx | 749 ------------------ .../components/dashboard-popout/AgentMap.tsx | 114 --- .../AgentMapCanvas.performance.test.tsx | 95 --- .../dashboard-popout/AgentMapCanvas.tsx | 412 ---------- .../AgentMapContentFilterItems.tsx | 59 -- .../AgentMapFilterCheckbox.tsx | 47 -- .../dashboard-popout/AgentMapFilterChips.tsx | 152 ---- .../AgentMapFilterPanel.test.tsx | 73 -- .../dashboard-popout/AgentMapFilterPanel.tsx | 363 --------- .../AgentMapFilterSection.tsx | 55 -- .../dashboard-popout/AgentMapMotion.test.tsx | 196 ----- .../AgentMapProjectContextMenu.tsx | 114 --- .../AgentMapProjectContextMenuLoader.tsx | 29 - .../AgentMapProjectLabel.test.tsx | 126 --- .../AgentMapQuestionMarker.tsx | 37 - .../AgentMapRingHover.test.tsx | 124 --- .../dashboard-popout/AgentMapScene.tsx | 304 ------- .../AgentMapSnapshotWorkspaceMenu.tsx | 100 --- .../AgentMapStatusGlow.test.tsx | 96 --- .../AgentMapTimeRangeField.test.tsx | 416 ---------- .../AgentMapTimeRangeField.tsx | 135 ---- .../AgentMapViewportControls.tsx | 47 -- ...ntMapWorkspaceContextMenu.boundary.test.ts | 35 - .../AgentMapWorkspaceContextMenu.test.tsx | 389 --------- .../AgentMapWorkspaceContextMenu.tsx | 167 ---- .../AgentMapWorkspaceContextMenuLoader.tsx | 35 - .../AgentMapWorktreeLabel.tsx | 40 - .../AgentMapWorktreeRingNode.tsx | 413 ---------- .../agent-dashboard-filter-options.ts | 24 - .../agent-map-agent-placement.ts | 58 -- .../dashboard-popout/agent-map-canvas-zoom.ts | 25 - .../agent-map-filter-labels.ts | 13 - .../agent-map-filter-summaries.ts | 54 -- .../dashboard-popout/agent-map-filter.test.ts | 113 --- .../dashboard-popout/agent-map-filter.ts | 106 --- .../agent-map-glow.performance.test.ts | 94 --- .../agent-map-hover-containment.test.ts | 49 -- .../agent-map-label-declutter.test.ts | 151 ---- .../agent-map-label-declutter.ts | 266 ------- .../agent-map-layout-metadata.ts | 69 -- .../dashboard-popout/agent-map-layout.test.ts | 658 --------------- .../dashboard-popout/agent-map-layout.ts | 325 -------- .../agent-map-lineage-chevron-path.test.ts | 136 ---- .../agent-map-lineage-chevron-path.ts | 125 --- .../agent-map-lineage-layout.ts | 224 ------ .../agent-map-navigation.test.ts | 42 - .../dashboard-popout/agent-map-navigation.ts | 53 -- .../agent-map-node-metadata.test.ts | 163 ---- .../agent-map-node-metadata.ts | 101 --- .../agent-map-node-presentation.test.ts | 17 - .../agent-map-node-presentation.ts | 44 - .../agent-map-packing-spatial-index.ts | 94 --- .../agent-map-project-placement.ts | 50 -- .../agent-map-quick-views.test.ts | 88 -- .../dashboard-popout/agent-map-quick-views.ts | 131 --- .../agent-map-render-test-harness.tsx | 136 ---- .../agent-map-spawn-clustering.ts | 27 - .../agent-map-time-filter.test.ts | 95 --- .../dashboard-popout/agent-map-time-filter.ts | 113 --- .../agent-map-viewport-transition.ts | 56 -- .../agent-map-workspace-identity.ts | 29 - .../agent-map-workspace-visibility.test.ts | 81 -- .../agent-map-workspace-visibility.ts | 25 - .../agent-map-worktree-active-status.test.ts | 43 - .../agent-map-worktree-active-status.ts | 25 - .../agent-map-worktree-host.ts | 25 - .../agent-map-worktree-lineage-layout.test.ts | 223 ------ .../agent-map-worktree-lineage-layout.ts | 279 ------- .../agent-map-worktree-packing.test.ts | 168 ---- .../agent-map-worktree-packing.ts | 267 ------- .../components/dashboard-popout/agent-map.css | 595 -------------- .../dashboard-popout/useAgentMapCanvasSize.ts | 35 - .../useAgentMapContextMenus.tsx | 156 ---- .../useAgentMapFilters.test.tsx | 69 -- .../dashboard-popout/useAgentMapFilters.ts | 105 --- .../useAgentMapMotionLayout.ts | 279 ------- .../useAgentMapPointerHold.ts | 47 -- .../useAgentMapSelectedFocus.ts | 63 -- .../useAgentMapViewportTransition.ts | 51 -- .../dashboard/AgentDashboardDrawer.test.tsx | 10 - ...nt-dashboard-performance-isolation.test.ts | 12 - .../src/i18n/en-runtime-required.json | 19 - src/renderer/src/i18n/locales/en.json | 75 +- src/renderer/src/i18n/locales/es.json | 21 - src/renderer/src/i18n/locales/fr.json | 75 +- src/renderer/src/i18n/locales/ja.json | 21 - src/renderer/src/i18n/locales/ko.json | 21 - src/renderer/src/i18n/locales/zh.json | 21 - .../agent-status-store-snapshot-budget.ts | 8 +- src/shared/dashboard-snapshot.ts | 4 +- .../pane-agent-identity-inventory.test.ts | 2 - 102 files changed, 31 insertions(+), 11751 deletions(-) delete mode 100644 src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMap.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMap.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapContentFilterItems.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapFilterCheckbox.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapFilterChips.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapFilterPanel.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapFilterPanel.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapFilterSection.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapMotion.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenu.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenuLoader.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapQuestionMarker.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapScene.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapViewportControls.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenuLoader.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-agent-placement.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-canvas-zoom.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-filter-labels.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-filter-summaries.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-filter.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-layout.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-lineage-layout.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-navigation.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-node-metadata.test.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-node-presentation.test.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-node-presentation.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-packing-spatial-index.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-project-placement.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-quick-views.test.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-quick-views.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-spawn-clustering.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-time-filter.test.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-time-filter.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-viewport-transition.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-workspace-identity.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-host.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.ts delete mode 100644 src/renderer/src/components/dashboard-popout/agent-map.css delete mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts delete mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapFilters.test.tsx delete mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapFilters.ts delete mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapMotionLayout.ts delete mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapPointerHold.ts delete mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapSelectedFocus.ts delete mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapViewportTransition.ts 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],