From 2a6552f27f277d8819782ea36a23585e1234fbe7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:19:32 -0700 Subject: [PATCH] fix(agent-map): isolate map work from main renderer --- .../dashboard-filter-payload-validation.ts | 44 +++ .../ipc/dashboard-payload-validation.test.ts | 47 ++++ src/main/ipc/dashboard-payload-validation.ts | 47 +--- src/main/ipc/dashboard-popout.test.ts | 9 + src/main/ipc/dashboard-popout.ts | 7 +- .../dashboard-workspace-payload-validation.ts | 80 ++++++ .../window/dashboard-popout-window.test.ts | 10 + src/main/window/dashboard-popout-window.ts | 9 +- src/preload/api-types.ts | 3 +- src/preload/index.ts | 9 +- src/renderer/src/App.tsx | 9 +- .../AgentDashboardFilterChips.tsx | 13 + .../AgentDashboardMapView.tsx | 129 +++++++++ .../AgentDashboardToolbar.tsx | 35 ++- .../AgentKanbanBoard.test.tsx | 89 +++++- .../dashboard-popout/AgentKanbanBoard.tsx | 146 ++++------ .../dashboard-popout/AgentMap.test.tsx | 122 +++++++- .../components/dashboard-popout/AgentMap.tsx | 24 +- .../dashboard-popout/AgentMapCanvas.tsx | 159 ++++++----- .../AgentMapProjectLabel.test.tsx | 6 +- .../dashboard-popout/AgentMapScene.tsx | 148 +++++++--- .../AgentMapWorktreeLabel.tsx | 39 +++ .../AgentMapWorktreeRingNode.tsx | 107 +++---- .../dashboard-popout/DashboardPopoutRoot.tsx | 7 +- .../dashboard-popout/agent-board-filtering.ts | 45 ++- .../agent-map-agent-placement.ts | 54 ++++ .../agent-map-label-declutter.test.ts | 27 +- .../agent-map-label-declutter.ts | 41 ++- .../agent-map-layout-metadata.ts | 53 ++++ .../dashboard-popout/agent-map-layout.test.ts | 125 ++++++++- .../dashboard-popout/agent-map-layout.ts | 260 +++++++++--------- .../agent-map-lineage-layout.ts | 12 +- .../agent-map-packing-spatial-index.ts | 2 +- .../agent-map-project-placement.ts | 50 ++++ .../agent-map-spawn-clustering.ts | 27 ++ .../agent-map-viewport-transition.ts | 56 ++++ .../agent-map-workspace-identity.ts | 11 +- .../agent-map-workspace-visibility.test.ts | 81 ++++++ .../agent-map-workspace-visibility.ts | 25 ++ .../agent-map-worktree-lineage-layout.test.ts | 43 ++- .../agent-map-worktree-lineage-layout.ts | 20 +- .../components/dashboard-popout/agent-map.css | 38 ++- .../dashboard-popout/useAgentMapCanvasSize.ts | 35 +++ .../useAgentMapSelectedFocus.ts | 63 +++++ .../useAgentMapViewportTransition.ts | 51 ++++ .../dashboard/AgentDashboardDrawer.test.tsx | 19 +- .../dashboard/AgentDashboardDrawer.tsx | 8 +- .../dashboard/DashboardPopoutBridge.tsx | 7 + ...nt-dashboard-performance-isolation.test.ts | 36 +++ ...ashboard-snapshot-folder-workspace.test.ts | 11 + .../build-dashboard-snapshot.test.ts | 18 ++ .../dashboard/build-dashboard-snapshot.ts | 75 +++-- .../dashboard/dashboard-card-bucket.ts | 15 + .../dashboard-snapshot-filter-options.ts | 27 ++ .../dashboard-snapshot-workspaces.ts | 8 +- .../dashboard-worktree-launch-options.test.ts | 21 +- .../dashboard-worktree-launch-options.ts | 15 +- .../settings/ExperimentalPane.test.tsx | 2 +- .../sidebar/AgentDashboardSidebarEntry.tsx | 90 ++++++ .../AgentDashboardSidebarHost.test.tsx | 75 +++++ .../sidebar/AgentDashboardSidebarHost.tsx | 43 +++ .../src/components/sidebar/Sidebar.test.tsx | 59 ---- .../components/sidebar/SidebarNav.test.tsx | 14 +- .../src/components/sidebar/SidebarNav.tsx | 124 +-------- src/renderer/src/components/sidebar/index.tsx | 39 +-- src/shared/constants.test.ts | 4 +- src/shared/constants.ts | 4 - src/shared/dashboard-snapshot.ts | 25 +- 68 files changed, 2402 insertions(+), 754 deletions(-) create mode 100644 src/main/ipc/dashboard-filter-payload-validation.ts create mode 100644 src/main/ipc/dashboard-workspace-payload-validation.ts create mode 100644 src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-agent-placement.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-project-placement.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-spawn-clustering.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-viewport-transition.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.ts create mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts create mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapSelectedFocus.ts create mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapViewportTransition.ts create mode 100644 src/renderer/src/components/dashboard/DashboardPopoutBridge.tsx create mode 100644 src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts create mode 100644 src/renderer/src/components/dashboard/dashboard-card-bucket.ts create mode 100644 src/renderer/src/components/dashboard/dashboard-snapshot-filter-options.ts create mode 100644 src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx create mode 100644 src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx create mode 100644 src/renderer/src/components/sidebar/AgentDashboardSidebarHost.tsx diff --git a/src/main/ipc/dashboard-filter-payload-validation.ts b/src/main/ipc/dashboard-filter-payload-validation.ts new file mode 100644 index 00000000000..694d2db2717 --- /dev/null +++ b/src/main/ipc/dashboard-filter-payload-validation.ts @@ -0,0 +1,44 @@ +import { DASHBOARD_MAX_LABEL_LENGTH } from '../../shared/dashboard-snapshot' + +const MAX_DASHBOARD_FILTER_OPTIONS = 500 +const MAX_ID_LENGTH = 4_096 + +function isBoundedString(value: unknown, maxLength: number, allowEmpty = false): value is string { + return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0) +} + +function isOptionalBoundedString(value: unknown, maxLength: number): boolean { + return value === undefined || isBoundedString(value, maxLength, true) +} + +function isDashboardFilterOptionList(value: unknown): boolean { + return ( + Array.isArray(value) && + value.length <= MAX_DASHBOARD_FILTER_OPTIONS && + value.every((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return false + } + const option = entry as Record + return ( + isBoundedString(option.id, MAX_ID_LENGTH) && + isBoundedString(option.label, DASHBOARD_MAX_LABEL_LENGTH, true) && + isOptionalBoundedString(option.color, MAX_ID_LENGTH) + ) + }) + ) +} + +export function isDashboardFilterOptions(value: unknown): boolean { + if (value === undefined) { + return true + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const options = value as Record + return ( + isDashboardFilterOptionList(options.projects) && + isDashboardFilterOptionList(options.workspaceStatuses) + ) +} diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index 4963c9cc0e4..f377ad3c9de 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -59,6 +59,23 @@ const SNAPSHOT = { askSummary: '{"question":"Proceed?"}' } ], + workspaces: [ + { + repoId: 'repo-1', + worktreeId: 'worktree-1', + repoName: 'Orca', + worktreeName: 'Dashboard', + parentWorktreeId: 'parent-worktree-1', + hostKind: 'ssh', + executionHostId: 'ssh:build-box', + workspaceKind: 'worktree', + workspaceStatusId: 'in-review', + workspaceStatusLabel: 'In review', + workspaceStatusColor: 'emerald', + hasReview: true, + review: { number: 11012, state: 'open' } + } + ], showIdle: false, filterOptions: { projects: [{ id: 'repo-1', label: 'Orca' }], @@ -204,6 +221,23 @@ describe('dashboard payload validation', () => { ).toBe(false) }) + it('validates bounded map workspace metadata independently of cards', () => { + expect(isDashboardSnapshot({ ...SNAPSHOT, cards: [] })).toBe(true) + expect(isDashboardSnapshot({ ...SNAPSHOT, workspaces: undefined })).toBe(true) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + workspaces: [{ ...SNAPSHOT.workspaces[0], executionHostId: 'build-box' }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + workspaces: [{ ...SNAPSHOT.workspaces[0], worktreeName: 'x'.repeat(1_025) }] + }) + ).toBe(false) + }) + it('validates bounded launch choices and spawn requests', () => { expect( isDashboardSnapshot({ @@ -303,6 +337,19 @@ describe('dashboard payload validation', () => { expect(admitted?.snapshot.cards.map((card) => card.paneKey)).toEqual(['tab-1:leaf-1']) }) + it('drops only malformed optional workspace metadata', () => { + const admitted = admitDashboardSnapshot({ + ...SNAPSHOT, + workspaces: [ + SNAPSHOT.workspaces[0], + { ...SNAPSHOT.workspaces[0], worktreeId: '', worktreeName: 'Invalid' } + ] + }) + + expect(admitted?.droppedCardCount).toBe(0) + expect(admitted?.snapshot.workspaces).toEqual([SNAPSHOT.workspaces[0]]) + }) + it('reports nothing dropped for a fully valid snapshot', () => { const admitted = admitDashboardSnapshot(SNAPSHOT) diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index 0adf8979e19..1625098df55 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -14,12 +14,16 @@ import { AGENT_TYPE_MAX_LENGTH } from '../../shared/agent-status-types' import { isDashboardLaunchOptions } from './dashboard-agent-launch-validation' +import { + admitDashboardWorkspaces, + isDashboardWorkspaceList +} from './dashboard-workspace-payload-validation' +import { isDashboardFilterOptions } from './dashboard-filter-payload-validation' export { isDashboardSpawnAgentArgs } from './dashboard-agent-launch-validation' const MAX_DASHBOARD_CARDS = 1_000 const MAX_DASHBOARD_SUBAGENTS = 100 const MAX_DASHBOARD_REPO_ICONS = 500 -const MAX_DASHBOARD_FILTER_OPTIONS = 500 // Why: sanitizing an image icon base64-decodes the whole data URI to read a // 24-byte header, and the renderer republishes the same icons every 250 ms. const MAX_CACHED_ICON_SRC_BYTES = 8 * 1024 * 1024 @@ -101,6 +105,7 @@ export function isDashboardSnapshot(value: unknown): value is DashboardSnapshot Array.isArray(snapshot.cards) && snapshot.cards.length <= MAX_DASHBOARD_CARDS && snapshot.cards.every(isDashboardCard) && + isDashboardWorkspaceList(snapshot.workspaces) && (snapshot.showIdle === undefined || typeof snapshot.showIdle === 'boolean') && isDashboardFilterOptions(snapshot.filterOptions) && isDashboardLaunchOptions(snapshot.launchableAgentsByWorktreeId) && @@ -126,10 +131,12 @@ export function admitDashboardSnapshot(value: unknown): DashboardSnapshotAdmissi return null } const snapshot = value as Record + const workspaces = admitDashboardWorkspaces(snapshot.workspaces) if ( !isFiniteNumber(snapshot.generatedAt) || !Array.isArray(snapshot.cards) || snapshot.cards.length > MAX_DASHBOARD_CARDS || + workspaces === null || (snapshot.showIdle !== undefined && typeof snapshot.showIdle !== 'boolean') || !isDashboardFilterOptions(snapshot.filterOptions) || !isDashboardLaunchOptions(snapshot.launchableAgentsByWorktreeId) || @@ -139,43 +146,15 @@ export function admitDashboardSnapshot(value: unknown): DashboardSnapshotAdmissi } const cards = snapshot.cards.filter(isDashboardCard) return { - snapshot: { ...(snapshot as unknown as DashboardSnapshot), cards }, + snapshot: { + ...(snapshot as unknown as DashboardSnapshot), + cards, + ...(workspaces ? { workspaces } : {}) + }, droppedCardCount: snapshot.cards.length - cards.length } } -function isDashboardFilterOptions(value: unknown): boolean { - if (value === undefined) { - return true - } - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false - } - const options = value as Record - return ( - isDashboardFilterOptionList(options.projects) && - isDashboardFilterOptionList(options.workspaceStatuses) - ) -} - -function isDashboardFilterOptionList(value: unknown): boolean { - return ( - Array.isArray(value) && - value.length <= MAX_DASHBOARD_FILTER_OPTIONS && - value.every((entry) => { - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { - return false - } - const option = entry as Record - return ( - isBoundedString(option.id, MAX_ID_LENGTH) && - isBoundedString(option.label, MAX_LABEL_LENGTH, true) && - isOptionalBoundedString(option.color, MAX_ID_LENGTH) - ) - }) - ) -} - /** Repo icons reach the pop-out's ``, so each one must survive the * same sanitizer the settings picker writes through. */ function isDashboardRepoIcons(value: unknown): boolean { diff --git a/src/main/ipc/dashboard-popout.test.ts b/src/main/ipc/dashboard-popout.test.ts index 22f24e1c00d..ce10b3556fc 100644 --- a/src/main/ipc/dashboard-popout.test.ts +++ b/src/main/ipc/dashboard-popout.test.ts @@ -127,6 +127,15 @@ describe('registerDashboardPopoutHandlers', () => { expect(createPopoutMock).toHaveBeenCalledWith(store, undefined, { 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 58d8058c07c..5b4781ec761 100644 --- a/src/main/ipc/dashboard-popout.ts +++ b/src/main/ipc/dashboard-popout.ts @@ -56,11 +56,14 @@ export function registerDashboardPopoutHandlers( } }) - ipcMain.handle('dashboardPopout:open', (event): void => { + ipcMain.handle('dashboardPopout:open', (event, view: unknown): void => { if (!isTrustedUIRenderer(event.sender) || !isDashboardEnabled(store)) { return } - createOrFocusDashboardPopout(store, undefined, { + if (view !== undefined && view !== 'board' && view !== 'map') { + return + } + createOrFocusDashboardPopout(store, view, { getKeybindings: () => keybindings?.getOverrides() }) }) diff --git a/src/main/ipc/dashboard-workspace-payload-validation.ts b/src/main/ipc/dashboard-workspace-payload-validation.ts new file mode 100644 index 00000000000..88e16d9af45 --- /dev/null +++ b/src/main/ipc/dashboard-workspace-payload-validation.ts @@ -0,0 +1,80 @@ +import { + DASHBOARD_MAX_LABEL_LENGTH, + DASHBOARD_MAX_MAP_WORKSPACES, + type DashboardWorkspace +} from '../../shared/dashboard-snapshot' +import { normalizeExecutionHostId } from '../../shared/execution-host' + +const MAX_ID_LENGTH = 4_096 +const HOST_KINDS = new Set(['local', 'ssh', 'wsl', 'remote']) +const WORKSPACE_KINDS = new Set(['worktree', 'folder']) +const REVIEW_STATES = new Set(['open', 'closed', 'merged', 'draft']) + +function isString(value: unknown, maxLength: number, allowEmpty = false): value is string { + return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0) +} + +function isOptionalString(value: unknown, maxLength: number): boolean { + return value === undefined || isString(value, maxLength, true) +} + +function isReview(value: unknown): boolean { + if (value === undefined) { + return true + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const review = value as Record + return ( + typeof review.number === 'number' && + Number.isFinite(review.number) && + review.number > 0 && + typeof review.state === 'string' && + REVIEW_STATES.has(review.state) + ) +} + +export function isDashboardWorkspace(value: unknown): value is DashboardWorkspace { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const workspace = value as Record + return ( + isString(workspace.repoId, MAX_ID_LENGTH) && + isString(workspace.worktreeId, MAX_ID_LENGTH) && + isString(workspace.repoName, DASHBOARD_MAX_LABEL_LENGTH, true) && + isString(workspace.worktreeName, DASHBOARD_MAX_LABEL_LENGTH, true) && + isOptionalString(workspace.parentWorktreeId, MAX_ID_LENGTH) && + typeof workspace.hostKind === 'string' && + HOST_KINDS.has(workspace.hostKind) && + isString(workspace.executionHostId, MAX_ID_LENGTH) && + normalizeExecutionHostId(workspace.executionHostId) !== null && + typeof workspace.workspaceKind === 'string' && + WORKSPACE_KINDS.has(workspace.workspaceKind) && + isOptionalString(workspace.workspaceStatusId, MAX_ID_LENGTH) && + isOptionalString(workspace.workspaceStatusLabel, DASHBOARD_MAX_LABEL_LENGTH) && + isOptionalString(workspace.workspaceStatusColor, MAX_ID_LENGTH) && + (workspace.hasReview === undefined || typeof workspace.hasReview === 'boolean') && + isReview(workspace.review) + ) +} + +export function isDashboardWorkspaceList(value: unknown): boolean { + return ( + value === undefined || + (Array.isArray(value) && + value.length <= DASHBOARD_MAX_MAP_WORKSPACES && + value.every(isDashboardWorkspace)) + ) +} + +export function admitDashboardWorkspaces(value: unknown): DashboardWorkspace[] | undefined | null { + if (value === undefined) { + return undefined + } + if (!Array.isArray(value) || value.length > DASHBOARD_MAX_MAP_WORKSPACES) { + return null + } + return value.filter(isDashboardWorkspace) +} diff --git a/src/main/window/dashboard-popout-window.test.ts b/src/main/window/dashboard-popout-window.test.ts index db27409b425..7ffa56d7b53 100644 --- a/src/main/window/dashboard-popout-window.test.ts +++ b/src/main/window/dashboard-popout-window.test.ts @@ -255,6 +255,16 @@ 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) diff --git a/src/main/window/dashboard-popout-window.ts b/src/main/window/dashboard-popout-window.ts index f5f21221485..d23742f375a 100644 --- a/src/main/window/dashboard-popout-window.ts +++ b/src/main/window/dashboard-popout-window.ts @@ -138,7 +138,7 @@ function resolveRestoredBounds(store: Store | null): { */ export function createOrFocusDashboardPopout( store: Store | null, - view: string = DEFAULT_VIEW, + view?: string, options: { getKeybindings?: () => KeybindingOverrides | undefined } = {} ): BrowserWindow { if (dashboardPopoutWindow && !dashboardPopoutWindow.isDestroyed()) { @@ -146,9 +146,14 @@ export function createOrFocusDashboardPopout( dashboardPopoutWindow.restore() } 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({ @@ -283,7 +288,7 @@ export function createOrFocusDashboardPopout( broadcastPopoutOpenChanged(false) }) - loadDashboardPopout(window, view) + loadDashboardPopout(window, initialView) return window } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0b73523e815..803d7317f8c 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2497,7 +2497,7 @@ export type PreloadApi = { ) => Promise } dashboard: { - openPopout: () => Promise + openPopout: (view?: 'board' | 'map') => Promise publishSnapshot: (snapshot: DashboardSnapshot) => Promise getPopoutOpen: () => Promise onPopoutOpenChanged: (callback: (open: boolean) => void) => () => void @@ -2508,6 +2508,7 @@ export type PreloadApi = { 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/index.ts b/src/preload/index.ts index 9925688ee69..b91b5fa30fc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2282,7 +2282,8 @@ const api = { dashboard: { // Open the pop-out dashboard window, or focus it if already open. - openPopout: (): Promise => ipcRenderer.invoke('dashboardPopout:open'), + openPopout: (view?: 'board' | 'map'): Promise => + ipcRenderer.invoke('dashboardPopout:open', view), // ── Producer side (main window) ────────────────────────────────────── publishSnapshot: (snapshot: DashboardSnapshot): Promise => @@ -2333,6 +2334,12 @@ const api = { 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/App.tsx b/src/renderer/src/App.tsx index 9874acecd66..8319d686597 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -92,7 +92,6 @@ import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher' import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling' import { useEditorExternalWatch } from './hooks/useEditorExternalWatch' import { useAutoAckViewedAgent } from './hooks/useAutoAckViewedAgent' -import { useDashboardPopoutBridge } from './components/dashboard/useDashboardPopoutBridge' import { useUnreadDockBadge } from './hooks/useUnreadDockBadge' import { resolvePrimarySelectionMiddleClickPaste, @@ -389,6 +388,7 @@ const FloatingTerminalPanel = lazy(() => ) // Why: lazy so the WebP asset + overlay module aren't fetched unless the experimental flag is on. const PetOverlay = lazy(() => import('./components/pet/PetOverlay')) +const DashboardPopoutBridge = lazy(() => import('./components/dashboard/DashboardPopoutBridge')) // Why: lazy so onboarding's step modules + assets aren't fetched for users past first-launch. const OnboardingFlow = lazy(() => import('./components/onboarding/OnboardingFlow')) @@ -757,8 +757,6 @@ function App(): React.JSX.Element { useEditorExternalWatch() useGlobalFileDrop() useAutoAckViewedAgent() - useDashboardPopoutBridge(settings?.experimentalAgentDashboardPopout === true) - useEffect(() => { return onOnboardingReopened(setOnboarding) }, []) @@ -2249,6 +2247,11 @@ function App(): React.JSX.Element { {/* Why: leaf-mounted retention sync keeps agent-status subscriptions out of the App render tree. */} + {settings?.experimentalAgentDashboardPopout === true ? ( + + + + ) : null} {/* Why: workspace activation is a hot path; activeWorktreeId in reset keys would remount whole surfaces during wake. */} string + showAgentlessWorkspaces: boolean onProjectToggle: (id: string) => void onStatusToggle: (id: string) => void onReviewToggle: (id: DashboardReviewFilter) => void + onAgentlessWorkspacesToggle: () => void onClear: () => void }): React.JSX.Element { return ( @@ -74,6 +78,15 @@ export function AgentDashboardFilterChips({ onRemove={() => onReviewToggle(id)} /> ))} + {showAgentlessWorkspaces ? ( + + ) : null} + {showAgentlessWorkspaces !== undefined && onShowAgentlessWorkspacesChange ? ( + <> + + {translate('dashboardPopout.map.filters.workspaceVisibility', 'Map content')} + + onShowAgentlessWorkspacesChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + + {translate( + 'dashboardPopout.map.filters.agentlessWorkspaces', + 'Workspaces without agents' + )} + + + + + + ) : null} {agentStates && agentStateCounts ? ( <> @@ -354,9 +385,11 @@ export function AgentDashboardToolbar({ projects={projects} statuses={statuses} reviewLabel={reviewLabel} + showAgentlessWorkspaces={showAgentlessWorkspaces === true} onProjectToggle={toggleProject} onStatusToggle={toggleStatus} onReviewToggle={toggleReview} + onAgentlessWorkspacesToggle={() => onShowAgentlessWorkspacesChange?.(false)} onClear={clearFilters} /> ) : null} diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx index 154e0e8bf02..58bb890d139 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx @@ -8,12 +8,15 @@ import { resolve } from 'node:path' import type { DashboardCard, DashboardFilterOptions, - DashboardSnapshot + DashboardSnapshot, + DashboardWorkspace } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' import { i18n } from '@/i18n/i18n' import { AgentKanbanBoard } from './AgentKanbanBoard' +const MAP_LOAD_TIMEOUT = { timeout: 5_000 } + // Stub the card and dialog so the board test stays free of xterm / Radix // machinery while still exercising the board-owned dialog wiring. vi.mock('./AgentKanbanCard', () => ({ @@ -92,12 +95,26 @@ function card(overrides: Partial): DashboardCard { } } +function workspace(overrides: Partial = {}): DashboardWorkspace { + return { + repoId: 'r1', + worktreeId: 'w1', + repoName: 'Repo', + worktreeName: 'wt', + hostKind: 'local', + executionHostId: 'local', + workspaceKind: 'worktree', + ...overrides + } +} + function renderBoard( cards: DashboardCard[], options: { showIdle?: boolean repoIconsByRepoId?: Record filterOptions?: DashboardFilterOptions + workspaces?: DashboardWorkspace[] } = {} ): void { const snapshot: DashboardSnapshot = { generatedAt: 1, cards, ...options } @@ -110,6 +127,10 @@ describe('AgentKanbanBoard', () => { beforeEach(async () => { await i18n.changeLanguage('en') localStorage.clear() + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + ) // The board relays seen-acks through the dashboard preload API. ;(window as unknown as { api: unknown }).api = { dashboard: { ackAgent } } }) @@ -118,6 +139,7 @@ describe('AgentKanbanBoard', () => { vi.useRealTimers() vi.clearAllMocks() vi.restoreAllMocks() + vi.unstubAllGlobals() }) it('renders the three default columns in order', () => { @@ -133,15 +155,17 @@ describe('AgentKanbanBoard', () => { ) expect(source).toContain("import { lazyWithRetry } from '@/lib/lazy-with-retry'") - expect(source).toMatch(/lazyWithRetry\(\s*\(\) => import\('\.\/AgentMap'\)/) - expect(source).not.toMatch(/import\s+(?:\{[^}]*\}|\w+)\s+from\s+['"]\.\/AgentMap['"]/) + expect(source).toMatch(/import\('\.\/AgentDashboardMapView'\)/) + expect(source).not.toMatch(/from\s+['"]\.\/(?:AgentMap|useAgentMap|agent-map-)/) }) it('keeps the dashboard and map available as separate views', async () => { renderBoard([]) fireEvent.click(screen.getByRole('button', { name: 'Agent Map' })) - expect(await screen.findByText('0 of 0 agents shown')).toBeInTheDocument() + expect( + await screen.findByText('0 of 0 agents shown', undefined, MAP_LOAD_TIMEOUT) + ).toBeInTheDocument() expect(screen.queryByText('Live containment map')).not.toBeInTheDocument() // The map has no rail of its own; its filters live in the shared toolbar. expect(screen.queryByRole('complementary', { name: 'Map filters' })).not.toBeInTheDocument() @@ -165,7 +189,9 @@ describe('AgentKanbanBoard', () => { }) ]) fireEvent.click(screen.getByRole('button', { name: 'Agent Map' })) - expect(await screen.findByText('2 of 2 agents shown')).toBeInTheDocument() + expect( + await screen.findByText('2 of 2 agents shown', undefined, MAP_LOAD_TIMEOUT) + ).toBeInTheDocument() fireEvent.pointerDown(screen.getByRole('button', { name: /^Filter/ })) fireEvent.click(await screen.findByRole('menuitemcheckbox', { name: /Working/ })) @@ -190,15 +216,58 @@ describe('AgentKanbanBoard', () => { expect(await screen.findByText('Agent states')).toBeInTheDocument() }) + it('toggles workspaces without agents from the shared map filter', async () => { + renderBoard([card({ paneKey: 'busy' })], { + workspaces: [workspace(), workspace({ worktreeId: 'empty', worktreeName: 'Empty child' })] + }) + fireEvent.click(screen.getByRole('button', { name: 'Agent Map' })) + + expect( + screen.queryByRole('button', { name: 'Open Empty child worktree details' }) + ).not.toBeInTheDocument() + fireEvent.pointerDown(screen.getByRole('button', { name: /^Filter/ })) + const workspaceToggle = await screen.findByRole('menuitemcheckbox', { + name: /Workspaces without agents/ + }) + fireEvent.click(workspaceToggle) + + expect(workspaceToggle).toHaveAttribute('aria-checked', 'true') + fireEvent.keyDown(document.body, { key: 'Escape' }) + + expect( + await screen.findByRole( + 'button', + { name: 'Open Empty child worktree details' }, + MAP_LOAD_TIMEOUT + ) + ).toBeInTheDocument() + expect(screen.getByText('Workspaces without agents')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Clear' })) + expect( + screen.queryByRole('button', { name: 'Open Empty child worktree details' }) + ).not.toBeInTheDocument() + }) + it('keeps the selected map visible beside its terminal panel', async () => { const agent = card({ paneKey: 'map-agent', conversationName: 'Map agent' }) render() - fireEvent.click(await screen.findByRole('button', { name: /Map agent/ })) + fireEvent.click(await screen.findByRole('button', { name: /Map agent/ }, MAP_LOAD_TIMEOUT)) expect(screen.getByLabelText('Nested project, workspace, and agent map')).toBeInTheDocument() - expect(screen.getByTestId('terminal-panel')).toHaveAttribute('data-pty-id', 'p1') + const terminalPanel = screen.getByTestId('terminal-panel') + expect(terminalPanel).toHaveAttribute('data-pty-id', 'p1') + expect(terminalPanel.parentElement).toHaveClass('flex-row-reverse') + expect( + screen.getByLabelText('Nested project, workspace, and agent map').closest('section') + ).toHaveClass('w-1/2', 'flex-none') expect(screen.getByRole('button', { name: /Map agent/ })).toHaveClass('is-selected') + expect(screen.getByRole('button', { name: /Map agent/ })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + expect(screen.getByText('200%')).toBeInTheDocument() expect(screen.queryByText('Map filters')).not.toBeInTheDocument() expect(screen.queryByTestId('terminal-dialog')).not.toBeInTheDocument() }) @@ -207,7 +276,7 @@ describe('AgentKanbanBoard', () => { const agent = card({ paneKey: 'map-agent', conversationName: 'Map agent' }) render() - fireEvent.click(await screen.findByRole('button', { name: /Map agent/ })) + fireEvent.click(await screen.findByRole('button', { name: /Map agent/ }, MAP_LOAD_TIMEOUT)) expect(screen.getByTestId('terminal-panel')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Agent Map' })) expect(screen.getByTestId('terminal-panel')).toBeInTheDocument() @@ -446,7 +515,9 @@ describe('AgentKanbanBoard', () => { ) - expect(await screen.findByRole('button', { name: /Fresh result/ })).toBeInTheDocument() + expect( + await screen.findByRole('button', { name: /Fresh result/ }, MAP_LOAD_TIMEOUT) + ).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /Fresh result/ })) expect(ackAgent).toHaveBeenCalledWith('fresh-result') diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx index f0d4461bfc6..376f38a1755 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx @@ -14,11 +14,7 @@ import { TooltipProvider } from '@/components/ui/tooltip' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { AgentKanbanCard } from './AgentKanbanCard' import { AgentDashboardToolbar } from './AgentDashboardToolbar' -import { - AgentTerminalDialog, - AgentTerminalPanel, - type AgentRevealArgs -} from './AgentTerminalDialog' +import { AgentTerminalDialog, type AgentRevealArgs } from './AgentTerminalDialog' import { EMPTY_DASHBOARD_FILTERS, filterDashboardCards, @@ -28,13 +24,13 @@ import './agent-board-transitions.css' import { translate } from '@/i18n/i18n' import { Button } from '@/components/ui/button' import { lazyWithRetry } from '@/lib/lazy-with-retry' -import { useAgentMapStateFilter } from './useAgentMapStateFilter' export type AgentDashboardView = 'map' | 'board' -const AgentMap = lazyWithRetry( - () => import('./AgentMap').then((module) => ({ default: module.AgentMap })), - { reloadKey: 'agent-map' } +const AgentDashboardMapView = lazyWithRetry( + () => + import('./AgentDashboardMapView').then((module) => ({ default: module.AgentDashboardMapView })), + { reloadKey: 'agent-dashboard-map-view' } ) /** Ack an agent in the pop-out window: relayed over IPC to the main renderer. @@ -164,6 +160,9 @@ type AgentKanbanBoardProps = { /** Header controls rendered before the close button. The in-window host * passes its settings menu; the pop-out renderer has no store to drive it. */ headerActions?: React.ReactNode + /** Opens the map outside this renderer. The in-window drawer uses this to + * hand map work to the dedicated pop-out window. */ + onOpenMap?: () => void /** The shared sidebar workspace menu is available only in the main renderer. */ workspaceContextMenusEnabled?: boolean onWorkspaceContextMenuOpenChange?: (open: boolean) => void @@ -182,6 +181,7 @@ export function AgentKanbanBoard({ onSleepWorkspace = sleepWorkspaceViaPopoutRelay, onClose, headerActions, + onOpenMap, workspaceContextMenusEnabled = false, onWorkspaceContextMenuOpenChange }: AgentKanbanBoardProps): React.JSX.Element { @@ -199,7 +199,6 @@ export function AgentKanbanBoard({ const [query, setQuery] = useState('') const searchInputRef = useRef(null) const [filters, setFilters] = useState(EMPTY_DASHBOARD_FILTERS) - const { agentStates, toggleAgentState, resetAgentStates } = useAgentMapStateFilter() const filteredCards = useMemo( () => filterDashboardCards(availableCards, query, filters), [availableCards, filters, query] @@ -210,7 +209,6 @@ export function AgentKanbanBoard({ [snapshot.cards] ) const [now, setNow] = useState(() => Date.now()) - const [terminalPanelSide, setTerminalPanelSide] = useState<'left' | 'right'>('right') useEffect(() => { if (!hasRelativeTimestamps) { return @@ -267,13 +265,17 @@ export function AgentKanbanBoard({ }, []) const handleViewChange = useCallback( (nextView: AgentDashboardView) => { + if (nextView === 'map' && onOpenMap) { + onOpenMap() + return + } if (nextView === view) { return } setOpenedCard(null) setView(nextView) }, - [view] + [onOpenMap, view] ) // Seen-state is the app-wide ack map (same signal as the sidebar's bold/mute @@ -286,13 +288,6 @@ export function AgentKanbanBoard({ }, [onAckAgent] ) - const handleOpenAdjacentTerminal = useCallback( - (card: DashboardCard, side: 'left' | 'right') => { - setTerminalPanelSide(side) - handleOpenTerminal(card) - }, - [handleOpenTerminal] - ) // Watching the open dialog counts as seeing state changes as they happen — // without this, an agent finishing while you watch would re-flag its card. useEffect(() => { @@ -362,76 +357,55 @@ export function AgentKanbanBoard({ ) : null} - {view !== 'board' ? ( -
- - - - {dialogCard ? ( - - ) : null} -
+ + + ) : ( -
- {/* Auto margins center the capped board and collapse during horizontal overflow. */} -
- {visibleBuckets.map((bucket) => ( - - ))} + <> + +
+ {/* Auto margins center the capped board and collapse during horizontal overflow. */} +
+ {visibleBuckets.map((bucket) => ( + + ))} +
-
+ )} {view === 'board' ? ( void + onOpenTerminal?: (card: DashboardCard) => void selectedPaneKey?: string | null compact?: boolean workspaceContextMenusEnabled?: boolean @@ -84,6 +85,10 @@ describe('AgentMap', () => { beforeEach(() => { Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Linux' }) + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + ) boundsSpy = vi .spyOn(Element.prototype, 'getBoundingClientRect') .mockImplementation(function getBounds(this: Element) { @@ -125,7 +130,7 @@ describe('AgentMap', () => { }) }) - it('renders the fixed amber marker only for unread agents', () => { + it('renders the amber marker only for unread agents', () => { const finished = card({ paneKey: 'done', conversationName: 'Finished agent', @@ -149,10 +154,46 @@ describe('AgentMap', () => { const onRing = String(-AGENT_MAP_AGENT_RADIUS * Math.SQRT1_2) expect(unreadMarker).toHaveAttribute('cx', onRing) expect(unreadMarker).toHaveAttribute('cy', onRing) - expect(unreadMarker).toHaveAttribute('r', '4.5') + expect(Number(unreadMarker?.getAttribute('r'))).toBeGreaterThanOrEqual( + AGENT_MAP_AGENT_RADIUS * 0.225 + ) + expect(unreadMarker).toHaveAttribute('vector-effect', 'none') expect(doneNode).toHaveAccessibleName(/unread/) }) + 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() @@ -167,7 +208,7 @@ describe('AgentMap', () => { 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, 'right') + expect(onOpenTerminal).toHaveBeenCalledWith(running) expect(ring).not.toHaveClass('is-open') }) @@ -228,7 +269,7 @@ describe('AgentMap', () => { ).toBeInTheDocument() }) - it('connects orchestrated workers beneath their visible parent', () => { + it('connects spawned workers beneath their visible parent', () => { const parent = card({ paneKey: 'parent', conversationName: 'Coordinator' }) const child = card({ paneKey: 'child', @@ -246,10 +287,33 @@ describe('AgentMap', () => { expect(screen.getByRole('button', { name: /Coordinator/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: /^Worker/ })).toBeInTheDocument() expect(links).toHaveLength(1) + expect(links[0]).toHaveClass('agent-map-lineage-link') expect(links[0]).toHaveAttribute('data-parent-pane-key', 'parent') expect(links[0]).toHaveAttribute('data-child-pane-key', 'child') }) + 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 { container } = renderMap([parent, child]) + const link = container.querySelector('[data-agent-map-cross-worktree-lineage-link]') + + expect(link).toHaveClass('agent-map-lineage-link', 'is-cross-worktree') + expect(link).toHaveAttribute('data-parent-pane-key', 'parent') + expect(link).toHaveAttribute('data-child-pane-key', 'child') + }) + it('connects visible child worktrees beneath their parent ring', () => { const { container } = renderMap([ card({ paneKey: 'parent', worktreeId: 'parent-worktree', worktreeName: 'Parent' }), @@ -279,17 +343,59 @@ describe('AgentMap', () => { renderMap([agent], { onOpenTerminal }) fireEvent.click(screen.getByRole('button', { name: /Agent alpha/ })) - expect(onOpenTerminal).toHaveBeenCalledWith(agent, 'right') + expect(onOpenTerminal).toHaveBeenCalledWith(agent) }) it('keeps a selected node visible while compacting around an adjacent terminal', () => { - renderMap([card()], { selectedPaneKey: 'pane-1', compact: true }) + const { container } = renderMap([card()], { selectedPaneKey: 'pane-1', compact: true }) - expect(screen.getByRole('button', { name: /Agent alpha/ })).toHaveClass('is-selected') + 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() expect(screen.queryByRole('group', { name: 'Host filter' })).not.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 }) diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.tsx index 6cbdf89c623..4e9c3c413ca 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.tsx @@ -6,7 +6,8 @@ import type { DashboardCard, DashboardCardHostKind, DashboardSleepWorkspaceArgs, - DashboardSpawnAgentArgs + DashboardSpawnAgentArgs, + DashboardWorkspace } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' import type { TuiAgent } from '../../../../shared/types' @@ -21,6 +22,7 @@ import './agent-map.css' type AgentMapProps = { cards: DashboardCard[] + workspaces?: DashboardWorkspace[] repoIconsByRepoId?: Record now: number className?: string @@ -32,7 +34,7 @@ type AgentMapProps = { launchableAgentsByWorktreeId?: Record workspaceContextMenusEnabled?: boolean onWorkspaceContextMenuOpenChange?: (open: boolean) => void - onOpenTerminal: (card: DashboardCard, side: 'left' | 'right') => void + onOpenTerminal: (card: DashboardCard) => void onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void } @@ -44,6 +46,7 @@ const ALL_AGENT_STATES: ReadonlySet = new Set([ 'done', 'idle' ]) +const EMPTY_WORKSPACES: DashboardWorkspace[] = [] function hostFilterLabel(filter: AgentMapHostFilter): string { switch (filter) { @@ -62,6 +65,7 @@ function hostFilterLabel(filter: AgentMapHostFilter): string { export function AgentMap({ cards, + workspaces = EMPTY_WORKSPACES, repoIconsByRepoId, now, className, @@ -88,8 +92,11 @@ export function AgentMap({ for (const card of cards) { counts[card.hostKind ?? 'local'] += 1 } + for (const workspace of workspaces) { + counts[workspace.hostKind] += 1 + } return counts - }, [cards]) + }, [cards, workspaces]) const visibleCards = useMemo( () => filterAgentMapCards({ @@ -99,9 +106,16 @@ export function AgentMap({ }), [cards, enabledStates, hostFilter] ) + const visibleWorkspaces = useMemo( + () => + hostFilter === 'all' + ? workspaces + : workspaces.filter((workspace) => workspace.hostKind === hostFilter), + [hostFilter, workspaces] + ) const layoutResult = useMemo( - () => updateAgentMapLayout(layoutCacheRef.current, visibleCards, now), - [visibleCards, now] + () => updateAgentMapLayout(layoutCacheRef.current, visibleCards, now, visibleWorkspaces), + [visibleCards, visibleWorkspaces, now] ) useEffect(() => { layoutCacheRef.current = layoutResult.cache diff --git a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx index da8a0637fd5..33cd767cc6a 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx @@ -8,6 +8,7 @@ import { useState } from 'react' import { translate } from '@/i18n/i18n' +import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' import type { DashboardCard, DashboardSleepWorkspaceArgs, @@ -15,7 +16,12 @@ import type { } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' import type { TuiAgent } from '../../../../shared/types' -import type { AgentMapAgentNode, AgentMapProjectRing, AgentMapLayout } from './agent-map-layout' +import { + AGENT_MAP_AGENT_RADIUS, + type AgentMapAgentNode, + type AgentMapProjectRing, + type AgentMapLayout +} from './agent-map-layout' import { AgentMapScene } from './AgentMapScene' import { AgentMapViewportControls } from './AgentMapViewportControls' import { @@ -23,14 +29,18 @@ import { navigableAgentMapAgents, nextDirectionalAgent } from './agent-map-navigation' +import type { AgentMapViewport } from './agent-map-viewport-transition' import { useAgentMapContextMenus } from './useAgentMapContextMenus' +import { useAgentMapCanvasSize } from './useAgentMapCanvasSize' +import { useAgentMapSelectedFocus } from './useAgentMapSelectedFocus' +import { useAgentMapViewportTransition } from './useAgentMapViewportTransition' const MIN_ZOOM = 0.7 -const MAX_ZOOM = 4 +const MAX_ZOOM = 24 +const AGENT_FOCUS_DURATION_MS = 240 +const AGENT_FOCUS_RADIUS_PX = 24 type Point = { x: number; y: number } -type ViewportSize = { width: number; height: number } -type Viewport = { center: Point; zoom: number } export type AgentMapCanvasHandle = { fit: () => void @@ -45,7 +55,7 @@ type AgentMapCanvasProps = { launchableAgentsByWorktreeId?: Record workspaceContextMenusEnabled?: boolean onWorkspaceContextMenuOpenChange?: (open: boolean) => void - onSelectAgent: (card: DashboardCard, side: 'left' | 'right') => void + onSelectAgent: (card: DashboardCard) => void onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void } @@ -54,6 +64,19 @@ function clamp(value: number, minimum: number, maximum: number): number { return Math.max(minimum, Math.min(maximum, value)) } +function agentFocusZoom(layout: AgentMapLayout, width: number, height: number): number { + const aspect = width / Math.max(1, height) + const baseWidth = Math.max(layout.width, layout.height * aspect) + return clamp( + Math.max( + 2, + (baseWidth * AGENT_FOCUS_RADIUS_PX) / (Math.max(1, width) * AGENT_MAP_AGENT_RADIUS) + ), + MIN_ZOOM, + MAX_ZOOM + ) +} + export const AgentMapCanvas = forwardRef( function AgentMapCanvas( { @@ -81,15 +104,18 @@ export const AgentMapCanvas = forwardRef(null) const viewportFrameRef = useRef(null) - const pendingViewportRef = useRef(null) + const pendingViewportRef = useRef(null) const interactionBoundsRef = useRef(null) - const focusedAgentRef = useRef<{ paneKey: string; x: number; y: number } | null>(null) const hasShownProjectsRef = useRef(layout.projects.length > 0) - const [size, setSize] = useState({ width: 800, height: 560 }) - const [viewport, setViewport] = useState({ + 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 viewportRef = useRef(viewport) const { contextMenus, onOpenProjectContextMenu, onOpenWorkspaceContextMenu } = useAgentMapContextMenus({ @@ -114,29 +140,61 @@ export const AgentMapCanvas = forwardRef { + const bounds = containerRef.current?.getBoundingClientRect() + return bounds && bounds.width > 0 && bounds.height > 0 + ? agentFocusZoom(layout, bounds.width, bounds.height) + : focusZoom + }, [focusZoom, layout]) - const applyViewport = useCallback((next: Viewport): void => { + const commitViewport = useCallback((next: AgentMapViewport): void => { viewportRef.current = next pendingViewportRef.current = null interactionBoundsRef.current = null setViewport(next) }, []) - const scheduleViewport = useCallback((next: Viewport): void => { - 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) - } + 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]) @@ -164,28 +222,6 @@ export const AgentMapCanvas = forwardRef { - const container = containerRef.current - if (!container || typeof ResizeObserver === 'undefined') { - return - } - const measure = (): void => { - const next = container.getBoundingClientRect() - if (next.width > 0 && next.height > 0) { - interactionBoundsRef.current = null - 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() - }, []) - useEffect( () => () => { if (viewportFrameRef.current !== null) { @@ -195,38 +231,11 @@ export const AgentMapCanvas = forwardRef { - if (!selectedPaneKey) { - focusedAgentRef.current = null - return - } - const selected = agents.find((agent) => agent.card.paneKey === selectedPaneKey) - if (!selected) { - focusedAgentRef.current = null - return - } - const focused = focusedAgentRef.current - if ( - focused?.paneKey === selectedPaneKey && - focused.x === selected.x && - focused.y === selected.y - ) { - return - } - focusedAgentRef.current = { paneKey: selectedPaneKey, x: selected.x, y: selected.y } - applyViewport({ - center: { x: selected.x, y: selected.y }, - zoom: Math.max(1, viewportRef.current.zoom) - }) - }, [agents, applyViewport, selectedPaneKey]) - const handleAgentKeyDown = useCallback( (event: React.KeyboardEvent, agent: AgentMapAgentNode): void => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() - const bounds = event.currentTarget.getBoundingClientRect() - const side = bounds.left + bounds.width / 2 <= window.innerWidth / 2 ? 'right' : 'left' - onSelectAgent(agent.card, side) + onSelectAgent(agent.card) return } const direction = diff --git a/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx index 48b5ac69f61..5cbf41b7abb 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx @@ -38,7 +38,7 @@ describe('AgentMapScene project labels', () => { }} zoom={1} labelScale={1} - mapScale={1} + mapScale={0.5} selectedPaneKey={null} allowAggregation nodeRefs={{ current: new Map() }} @@ -50,7 +50,11 @@ describe('AgentMapScene project labels', () => { 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') }) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx index e8fb26709b2..edfa866b966 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo, type MutableRefObject } from 'react' +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' @@ -10,7 +10,9 @@ import type { AgentMapProjectRing, AgentMapWorktreeRing } from './agent-map-layout' +import { shouldAggregateAgentMapWorktree } from './agent-map-layout' import { selectVisibleAgentMapLabels } from './agent-map-label-declutter' +import { AgentMapWorktreeLabel } from './AgentMapWorktreeLabel' import { AgentMapWorktreeRingNode } from './AgentMapWorktreeRingNode' type AgentMapSceneProps = { @@ -23,7 +25,7 @@ type AgentMapSceneProps = { allowAggregation: boolean launchableAgentsByWorktreeId?: Record nodeRefs: MutableRefObject> - onSelectAgent: (card: DashboardCard, side: 'left' | 'right') => void + onSelectAgent: (card: DashboardCard) => void onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void onOpenProjectContextMenu?: ( event: React.MouseEvent, @@ -43,6 +45,23 @@ function worktreeLineagePath(parent: AgentMapWorktreeRing, child: AgentMapWorktr 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 { + const dx = child.x - parent.x + const dy = child.y - parent.y + const distance = Math.hypot(dx, dy) + if (distance === 0) { + return `M ${parent.x} ${parent.y}` + } + const unitX = dx / distance + const unitY = dy / distance + return `M ${parent.x + unitX * parent.radius} ${parent.y + unitY * parent.radius} L ${child.x - unitX * child.radius} ${child.y - unitY * child.radius}` +} + /** Memoization keeps pointer panning to one SVG viewBox write, not a map rerender. */ export const AgentMapScene = memo(function AgentMapScene({ layout, @@ -60,14 +79,52 @@ export const AgentMapScene = memo(function AgentMapScene({ onOpenWorkspaceContextMenu, onAgentKeyDown }: AgentMapSceneProps): React.JSX.Element { + const [activeWorktreeId, setActiveWorktreeId] = useState(null) + const handleLabelActiveChange = useCallback((worktreeId: string, active: boolean): void => { + setActiveWorktreeId((current) => + active ? worktreeId : current === worktreeId ? null : current + ) + }, []) const visibleLabels = useMemo( () => selectVisibleAgentMapLabels(layout, labelScale, mapScale), [labelScale, layout, mapScale] ) + 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 projectCountText = translate( + 'dashboardPopout.map.projectCount', + '{{agents}} agents · {{workspaces}} workspaces', + { agents: project.agentCount, workspaces: project.worktrees.length } + ).toUpperCase() + const crossWorktreeLineage = project.worktrees.flatMap((worktree) => + worktree.agents.flatMap((child) => { + const parent = child.card.parentPaneKey + ? visibleAgentsByPaneKey.get(child.card.parentPaneKey) + : undefined + const childLocation = visibleAgentsByPaneKey.get(child.card.paneKey) + return parent && childLocation && parent.worktreeId !== childLocation.worktreeId + ? [{ parent: parent.agent, child }] + : [] + }) + ) return ( - - -
- - {project.name.toUpperCase()} -
-
- {visibleLabels.projectCountIds.has(project.id) ? ( - - {translate( - 'dashboardPopout.map.projectCount', - '{{agents}} agents · {{workspaces}} workspaces', - { agents: project.agentCount, workspaces: project.worktrees.length } - ).toUpperCase()} - - ) : null} -
{project.worktrees.map((child) => { const parent = child.parentId ? worktreesById.get(child.parentId) : undefined @@ -130,25 +158,79 @@ export const AgentMapScene = memo(function AgentMapScene({ ) })} + + {crossWorktreeLineage.map(({ parent, child }) => ( + + ))} + {project.worktrees.map((worktree) => ( ))} + + {project.worktrees.map((worktree) => ( + + ))} + + + +
+ + + {project.name.toUpperCase()} + +
+
+ {visibleLabels.projectCountIds.has(project.id) ? ( + + {projectCountText} + + ) : null} +
) })} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx new file mode 100644 index 00000000000..76398b29daf --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx @@ -0,0 +1,39 @@ +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 { + const showCount = visible && (active || 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 index e016954805e..81371130af7 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -19,20 +19,18 @@ type AgentMapWorktreeRingNodeProps = { project: AgentMapProjectRing worktree: AgentMapWorktreeRing zoom: number - labelScale: number mapScale: number selectedPaneKey: string | null allowAggregation: boolean - /** Decided by the scene's declutter pass so labels never stack on each other. */ - labelVisible: boolean launchableAgents?: readonly TuiAgent[] nodeRefs: MutableRefObject> - onSelectAgent: (card: DashboardCard, side: 'left' | 'right') => void + onSelectAgent: (card: DashboardCard) => void onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void onOpenWorkspaceContextMenu?: ( event: React.MouseEvent, worktree: AgentMapWorktreeRing ) => void + onLabelActiveChange: (worktreeId: string, active: boolean) => void onAgentKeyDown: (event: React.KeyboardEvent, agent: AgentMapAgentNode) => void } @@ -57,15 +55,15 @@ function lineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): strin return `M ${parent.x} ${startY} L ${parent.x} ${branchY} L ${child.x} ${branchY} L ${child.x} ${endY}` } -function panelSideFor(element: Element): 'left' | 'right' { - const bounds = element.getBoundingClientRect() - return bounds.left + bounds.width / 2 <= window.innerWidth / 2 ? 'right' : 'left' -} - 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) +} + function WorktreeDetails({ project, worktree, @@ -106,28 +104,34 @@ function WorktreeDetails({ {translate('dashboardPopout.map.runningAgents', 'Agents')}
- {worktree.agents.map((agent) => ( - - ))} + + + )) + )}
{onSpawnAgent ? ( @@ -170,31 +174,40 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ project, worktree, zoom, - labelScale, mapScale, selectedPaneKey, allowAggregation, - labelVisible, launchableAgents, nodeRefs, onSelectAgent, onSpawnAgent, onOpenWorkspaceContextMenu, + onLabelActiveChange, onAgentKeyDown }: AgentMapWorktreeRingNodeProps): React.JSX.Element { const [detailsOpen, setDetailsOpen] = useState(false) const selected = worktree.agents.some((agent) => agent.card.paneKey === selectedPaneKey) const aggregate = !selected && shouldAggregateAgentMapWorktree(worktree, zoom, allowAggregation) const agentsByPaneKey = new Map(worktree.agents.map((agent) => [agent.card.paneKey, agent])) - const screenRadius = worktree.radius * mapScale - const showCount = labelVisible && screenRadius >= 80 return ( - + onLabelActiveChange(worktree.id, true)} + onPointerLeave={() => onLabelActiveChange(worktree.id, false)} + onFocus={() => onLabelActiveChange(worktree.id, true)} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + onLabelActiveChange(worktree.id, false) + } + }} + > - - - {worktree.name} - - - {translate( - 'dashboardPopout.map.agentCount', - worktree.agents.length === 1 ? '{{count}} agent' : '{{count}} agents', - { count: worktree.agents.length } - )} - - {aggregate ? ( onSelectAgent(agent.card, panelSideFor(event.currentTarget))} + onClick={(event) => { + event.currentTarget.focus() + onSelectAgent(agent.card) + }} onKeyDown={(event) => onAgentKeyDown(event, agent)} > @@ -318,7 +320,8 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ data-agent-unread-marker="" cx={-agent.radius * Math.SQRT1_2} cy={-agent.radius * Math.SQRT1_2} - r={4.5} + r={agent.radius * 0.225 * agentMapAttentionMarkerScale(mapScale)} + vectorEffect="none" aria-hidden="true" /> ) : null} diff --git a/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx b/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx index dd448fd52e6..557036c569a 100644 --- a/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx +++ b/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react' import { AgentKanbanBoard, type AgentDashboardView } from './AgentKanbanBoard' import { useDashboardSnapshot } from './useDashboardSnapshot' @@ -12,7 +13,9 @@ type DashboardPopoutRootProps = { */ export function DashboardPopoutRoot(_props: DashboardPopoutRootProps): React.JSX.Element { const snapshot = useDashboardSnapshot() - const initialView: AgentDashboardView = + const [view, setView] = useState(() => _props.view === 'map' || _props.view === 'rings' ? 'map' : 'board' - return + ) + useEffect(() => window.api.dashboard.onViewRequested(setView), []) + return } diff --git a/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts b/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts index d7a4c132150..14470931ca8 100644 --- a/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts +++ b/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts @@ -1,4 +1,8 @@ -import type { DashboardCard, DashboardCardReview } from '../../../../shared/dashboard-snapshot' +import type { + DashboardCard, + DashboardCardReview, + DashboardWorkspace +} from '../../../../shared/dashboard-snapshot' export type DashboardReviewFilter = DashboardCardReview['state'] | 'none' @@ -36,6 +40,34 @@ function cardSearchText(card: DashboardCard): string { .toLocaleLowerCase() } +function workspaceSearchText(workspace: DashboardWorkspace): string { + return [ + workspace.worktreeName, + workspace.repoName, + workspace.review ? `#${workspace.review.number}` : '' + ] + .filter(Boolean) + .join(' ') + .toLocaleLowerCase() +} + +function workspaceMatchesFilters( + workspace: DashboardWorkspace, + normalizedQuery: string, + filters: DashboardFilters +): boolean { + const reviewState = workspace.review?.state ?? (workspace.hasReview ? null : 'none') + return ( + (normalizedQuery.length === 0 || workspaceSearchText(workspace).includes(normalizedQuery)) && + (filters.projects.length === 0 || filters.projects.includes(workspace.repoId)) && + (filters.workspaceStatuses.length === 0 || + (workspace.workspaceStatusId !== undefined && + filters.workspaceStatuses.includes(workspace.workspaceStatusId))) && + (filters.reviewStates.length === 0 || + (reviewState !== null && filters.reviewStates.includes(reviewState))) + ) +} + export function filterDashboardCards( cards: DashboardCard[], query: string, @@ -56,6 +88,17 @@ export function filterDashboardCards( }) } +export function filterDashboardWorkspaces( + workspaces: DashboardWorkspace[], + query: string, + filters: DashboardFilters +): DashboardWorkspace[] { + const normalizedQuery = query.trim().toLocaleLowerCase() + return workspaces.filter((workspace) => + workspaceMatchesFilters(workspace, normalizedQuery, filters) + ) +} + export function toggleDashboardFilter(values: T[], value: T): T[] { return values.includes(value) ? values.filter((candidate) => candidate !== value) 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 new file mode 100644 index 00000000000..56978357359 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-agent-placement.ts @@ -0,0 +1,54 @@ +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { agentMapDurationMinutes, 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: DashboardCard['dotState'] +}[] { + 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-label-declutter.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts index 436ffa6a4fc..28c71c881a1 100644 --- 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 @@ -23,7 +23,7 @@ function worktree(overrides: Partial = {}): AgentMapWorktr x: 0, y: 0, radius: 62, - // Only the count matters to the declutter pass; the nodes never reach it. + // Sparse placeholders keep tests that only care about label-to-label collisions concise. agents: Array.from({ length: total }) as AgentMapWorktreeRing['agents'], statusCounts: counts, quiet: counts.idle === total, @@ -80,6 +80,31 @@ describe('selectVisibleAgentMapLabels', () => { 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) + expect(labels.worktreeAgentSafeIds.size).toBe(0) + }) + + it('keeps a collision-suppressed workspace title safe to reveal away from agents', () => { + 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 labels = selectVisibleAgentMapLabels(layout, 1, 1) + + expect([...labels.worktreeIds]).toEqual(['busy']) + expect([...labels.worktreeAgentSafeIds].sort()).toEqual(['busy', 'calm']) + }) + 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 }) }), 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 index 1e734f4940c..020d8453dd2 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts @@ -14,6 +14,7 @@ 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 @@ -37,6 +38,8 @@ type LabelGrid = Map> export type AgentMapVisibleLabels = { /** Worktree ring ids whose name can render without colliding. */ worktreeIds: Set + /** Worktree names that hover/focus may reveal without covering an agent. */ + worktreeAgentSafeIds: 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 @@ -46,6 +49,14 @@ function textWidth(text: string, fontPx: number, ratio = GLYPH_WIDTH_RATIO): num return text.length * fontPx * ratio } +export function agentMapWorktreeLabelWidth(text: string): number { + return textWidth(text, WORKTREE_LABEL_FONT_PX) +} + +export function agentMapCountLabelWidth(text: string): number { + return textWidth(text, COUNT_FONT_PX) +} + function boxesOverlap(a: LabelBox, b: LabelBox): boolean { return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom } @@ -149,6 +160,26 @@ 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 — @@ -161,7 +192,10 @@ export function selectVisibleAgentMapLabels( 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( @@ -195,6 +229,7 @@ export function selectVisibleAgentMapLabels( }) const worktreeIds = new Set() + const worktreeAgentSafeIds = new Set() for (const worktree of candidates.slice(0, MAX_LABEL_CANDIDATES)) { const box = baselineBox( worktree.x, @@ -204,6 +239,10 @@ export function selectVisibleAgentMapLabels( WORKTREE_LABEL_FONT_PX, WORKTREE_LABEL_BASELINE ) + if (collides(agentGrid, box)) { + continue + } + worktreeAgentSafeIds.add(worktree.id) if (collides(grid, box)) { continue } @@ -230,5 +269,5 @@ export function selectVisibleAgentMapLabels( projectCountIds.add(project.id) } - return { worktreeIds, projectCountIds } + return { worktreeIds, worktreeAgentSafeIds, 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 new file mode 100644 index 00000000000..69238b73cf3 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts @@ -0,0 +1,53 @@ +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import type { AgentMapLayout, AgentMapStatusCounts } from './agent-map-layout' +import { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata' + +function emptyStatusCounts(): AgentMapStatusCounts { + return { working: 0, blocked: 0, waiting: 0, done: 0, idle: 0 } +} + +export function refreshAgentMapMetadata( + geometry: AgentMapLayout, + cards: DashboardCard[], + now: number +): AgentMapLayout { + const cardsByPaneKey = new Map(cards.map((card) => [card.paneKey, card])) + const projects = geometry.projects.map((project) => { + let projectName = project.name + let agentCount = 0 + const worktrees = project.worktrees.map((worktree) => { + let worktreeName = worktree.name + let workspaceKind = worktree.workspaceKind + const statusCounts = emptyStatusCounts() + 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' + agentCount += 1 + statusCounts[agentMapNodeStatus(card)] += 1 + return [ + { + ...agent, + card, + durationMinutes: agentMapDurationMinutes(card, now), + status: agentMapNodeStatus(card) + } + ] + }) + return { + ...worktree, + name: worktreeName, + workspaceKind, + agents, + statusCounts, + quiet: statusCounts.idle === 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 index 7abdd897ccc..52c47be08f5 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +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, @@ -46,7 +47,39 @@ function card(overrides: Partial = {}): DashboardCard { } } +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' }), @@ -109,6 +142,35 @@ describe('agent map layout', () => { ) }) + 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()]], [ @@ -131,7 +193,7 @@ describe('agent map layout', () => { expect((top + bottom) / 2).toBeCloseTo(layout.height / 2) }) - it('places orchestrated descendants beneath their direct parent inside the workspace', () => { + it('places spawned descendants beneath their direct parent inside the workspace', () => { const layout = deriveAgentMapLayout( [ card({ paneKey: 'parent' }), @@ -158,7 +220,7 @@ describe('agent map layout', () => { } }) - it('packs high-fanout orchestrated children into a compact deterministic cluster', () => { + it('packs high-fanout spawned children into a compact deterministic cluster', () => { const cards = [ card({ paneKey: 'parent' }), ...Array.from({ length: 29 }, (_, index) => @@ -233,6 +295,57 @@ describe('agent map layout', () => { } }) + 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' }), @@ -254,7 +367,7 @@ describe('agent map layout', () => { expect(updated.cache.packingGeneration).toBe(2) }) - it('repacks cached geometry when an orchestration parent changes', () => { + it('repacks cached geometry when a spawn parent changes', () => { const cards = [ card({ paneKey: 'parent-a' }), card({ paneKey: 'parent-b' }), @@ -437,7 +550,7 @@ describe('agent map layout', () => { expect(minimumDistance).toBeGreaterThanOrEqual(AGENT_MAP_AGENT_RADIUS * 2) }) - it('keeps deeply nested orchestration finite without recursive stack growth', () => { + it('keeps deeply nested spawn lineage finite without recursive stack growth', () => { const layout = deriveAgentMapLayout( Array.from({ length: 5_000 }, (_, index) => card({ @@ -461,7 +574,7 @@ describe('agent map layout', () => { } }) - it('wraps very large orchestration fanout without overlap', () => { + it('wraps very large spawn fanout without overlap', () => { const layout = deriveAgentMapLayout( [ card({ paneKey: 'parent' }), diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout.ts index e30b21d54d7..68602f95c04 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-layout.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout.ts @@ -1,8 +1,14 @@ 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 } 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' @@ -10,17 +16,18 @@ import { layoutAgentMapWorktreeLineage } from './agent-map-worktree-lineage-layo type DashboardCard = DashboardSnapshotTypes.DashboardCard type DashboardCardDotState = DashboardSnapshotTypes.DashboardCardDotState +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 -const PROJECT_GAP = 32 -const PROJECT_PADDING = 16 +const PROJECT_PADDING = 12 const WORLD_MARGIN = 32 -const GOLDEN_ANGLE = 2.399963229728653 +const RING_CONTENT_OFFSET = AGENT_MAP_RING_HEADER_HEIGHT / 2 export type AgentMapStatusCounts = Record @@ -36,6 +43,8 @@ export type AgentMapAgentNode = { 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'] name: string @@ -73,6 +82,9 @@ export type AgentMapLayoutCache = { type LocalWorktree = Omit & { x: number; y: number } type LocalProject = Omit & { + x: number + y: number + clusterParentId?: string worktrees: LocalWorktree[] } @@ -80,17 +92,16 @@ function compareStable(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0 } -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 agentMapTopologyKey(cards: DashboardCard[]): string { - return cards.map(agentMapCardTopologyIdentity).sort(compareStable).join('|') +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( @@ -112,56 +123,39 @@ function emptyStatusCounts(): AgentMapStatusCounts { function worktreeRadius(agentCount: number): number { return Math.max( - 62, - 34 + Math.ceil(Math.sqrt(Math.max(1, agentCount))) * (AGENT_MAP_AGENT_RADIUS + 8) + 52, + 24 + Math.ceil(Math.sqrt(Math.max(1, agentCount))) * (AGENT_MAP_AGENT_RADIUS + 8) ) } -function placeAgents( - worktreeId: string, +function buildLocalWorktree( + id: string, cards: DashboardCard[], - radius: number, - now: number -): AgentMapAgentNode[] { - const availableRadius = Math.max(0, radius - AGENT_MAP_AGENT_RADIUS - 10) - const sorted = [...cards].sort((a, b) => compareStable(a.paneKey, b.paneKey)) - 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: AGENT_MAP_AGENT_RADIUS, - durationMinutes: agentMapDurationMinutes(card, now), - status: agentMapNodeStatus(card) - } - }) -} - -function buildLocalWorktree(id: string, cards: DashboardCard[], now: number): LocalWorktree { + now: number, + workspace?: DashboardWorkspace +): LocalWorktree { const lineageLayout = layoutAgentMapLineage(cards, AGENT_MAP_AGENT_RADIUS) - const radius = lineageLayout?.radius ?? worktreeRadius(cards.length) + const contentRadius = lineageLayout?.radius ?? worktreeRadius(cards.length) + const radius = contentRadius + RING_CONTENT_OFFSET const statusCounts = emptyStatusCounts() for (const card of cards) { statusCounts[agentMapNodeStatus(card)] += 1 } + const executionHostId = workspace?.executionHostId ?? cards[0]?.executionHostId + const parentWorktreeId = workspace?.parentWorktreeId ?? cards[0]?.parentWorktreeId return { id, - parentId: cards[0]?.parentWorktreeId - ? agentMapWorktreeIdentityFromParts(cards[0].parentWorktreeId, cards[0].executionHostId) + parentId: parentWorktreeId + ? agentMapWorktreeIdentityFromParts(parentWorktreeId, executionHostId) : undefined, - worktreeId: cards[0]?.worktreeId ?? id, - executionHostId: cards[0]?.executionHostId, - name: cards[0]?.worktreeName ?? id, - workspaceKind: cards[0]?.workspaceKind ?? 'worktree', + worktreeId: workspace?.worktreeId ?? cards[0]?.worktreeId ?? id, + executionHostId, + name: workspace?.worktreeName ?? cards[0]?.worktreeName ?? id, + workspaceKind: workspace?.workspaceKind ?? cards[0]?.workspaceKind ?? 'worktree', x: 0, y: 0, radius, - agents: + agents: ( lineageLayout?.agents.map(({ card, x, y }) => ({ card, x, @@ -169,13 +163,27 @@ function buildLocalWorktree(id: string, cards: DashboardCard[], now: number): Lo radius: AGENT_MAP_AGENT_RADIUS, durationMinutes: agentMapDurationMinutes(card, now), status: agentMapNodeStatus(card) - })) ?? placeAgents(id, cards, radius, now), + })) ?? + placeAgentMapAgents({ + worktreeId: id, + cards, + radius: contentRadius, + agentRadius: AGENT_MAP_AGENT_RADIUS, + now + }) + ).map((agent) => ({ ...agent, y: agent.y + RING_CONTENT_OFFSET })), statusCounts, quiet: statusCounts.idle === cards.length } } -function buildLocalProject(id: string, cards: DashboardCard[], now: number): LocalProject { +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) @@ -186,128 +194,106 @@ function buildLocalProject(id: string, cards: DashboardCard[], now: number): Loc byWorktree.set(identity, [card]) } } - const worktrees = layoutAgentMapWorktreeLineage( + 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)) + .map(([worktreeId, worktreeCards]) => ({ + ...buildLocalWorktree(worktreeId, worktreeCards, now, workspacesById.get(worktreeId)), + clusterParentId: selectAgentMapSpawnParentContainer( + worktreeCards, + cardsByPaneKey, + agentMapWorktreeIdentity + ) + })) ) - const radius = Math.max( - 96, - ...worktrees.map( + 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 ?? id, - radius, + 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): AgentMapLayout { - const topologyKey = agentMapTopologyKey(cards) - if (cards.length === 0) { +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() + const byProject = new Map() for (const card of cards) { - const current = byProject.get(card.repoId) - if (current) { - current.push(card) - } else { - byProject.set(card.repoId, [card]) - } + 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, projectCards]) => buildLocalProject(projectId, projectCards, now)) - const maxProjectRadius = Math.max(...localProjects.map((project) => project.radius)) - const projectSpanWidth = localProjects.reduce((sum, project) => sum + project.radius * 2, 0) - const naturalWidth = - projectSpanWidth + PROJECT_GAP * (localProjects.length - 1) + WORLD_MARGIN * 2 - const naturalHeight = maxProjectRadius * 2 + WORLD_MARGIN * 2 - const width = Math.max(900, naturalWidth) - const height = Math.max(560, naturalHeight) - const centerY = WORLD_MARGIN + maxProjectRadius + (height - naturalHeight) / 2 - let cursorX = WORLD_MARGIN + (width - naturalWidth) / 2 - const projects = localProjects.map((project): AgentMapProjectRing => { - const centerX = cursorX + project.radius - cursorX += project.radius * 2 + PROJECT_GAP + .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, - x: centerX, - y: centerY, worktrees: project.worktrees.map((worktree) => ({ ...worktree, - x: centerX + worktree.x, - y: centerY + worktree.y, + x: project.x + worktree.x, + y: project.y + worktree.y, agents: worktree.agents.map((agent) => ({ ...agent, - x: centerX + worktree.x + agent.x, - y: centerY + worktree.y + agent.y + x: project.x + worktree.x + agent.x, + y: project.y + worktree.y + agent.y })) })) } }) - return { projects, width, height, topologyKey } -} - -function refreshAgentMapMetadata( - geometry: AgentMapLayout, - cards: DashboardCard[], - now: number -): AgentMapLayout { - const cardsByPaneKey = new Map(cards.map((card) => [card.paneKey, card])) - const projects = geometry.projects.map((project) => { - let projectName = project.name - let agentCount = 0 - const worktrees = project.worktrees.map((worktree) => { - let worktreeName = worktree.name - let workspaceKind = worktree.workspaceKind - const statusCounts = emptyStatusCounts() - 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' - agentCount += 1 - statusCounts[agentMapNodeStatus(card)] += 1 - return [ - { - ...agent, - card, - durationMinutes: agentMapDurationMinutes(card, now), - status: agentMapNodeStatus(card) - } - ] - }) - return { - ...worktree, - name: worktreeName, - workspaceKind, - agents, - statusCounts, - quiet: statusCounts.idle === agents.length - } - }) - return { ...project, name: projectName, worktrees, agentCount } - }) - return { ...geometry, projects } + return { projects, width: framed.width, height: framed.height, topologyKey } } export function updateAgentMapLayout( cache: AgentMapLayoutCache | null, cards: DashboardCard[], - now: number + now: number, + workspaces: DashboardWorkspace[] = [] ): { cache: AgentMapLayoutCache; layout: AgentMapLayout } { - const topologyKey = agentMapTopologyKey(cards) + const topologyKey = agentMapTopologyKey(cards, workspaces) if (!cache || cache.topologyKey !== topologyKey) { - const geometry = deriveAgentMapLayout(cards, now) + const geometry = deriveAgentMapLayout(cards, now, workspaces) return { cache: { topologyKey, 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 index da605dfaefd..f5d3665c483 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-lineage-layout.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-lineage-layout.ts @@ -1,10 +1,10 @@ import type { DashboardCard } from '../../../../shared/dashboard-snapshot' import { packAgentMapWorktrees } from './agent-map-worktree-packing' -const HORIZONTAL_GAP = 58 -const VERTICAL_GAP = 62 -const FAMILY_PADDING = 12 -const WORKTREE_PADDING = 10 +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 @@ -165,7 +165,7 @@ function layoutBoundedLineage( } 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(62, family.radius + WORKTREE_PADDING) } + return { agents: family.agents, radius: Math.max(52, family.radius + WORKTREE_PADDING) } } export function layoutAgentMapLineage( @@ -217,7 +217,7 @@ export function layoutAgentMapLineage( ) .sort((a, b) => compareStable(a.card.paneKey, b.card.paneKey)), radius: Math.max( - 62, + 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-packing-spatial-index.ts b/src/renderer/src/components/dashboard-popout/agent-map-packing-spatial-index.ts index 81f30f893b8..83aefde2f06 100644 --- 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 @@ -1,4 +1,4 @@ -export const AGENT_MAP_WORKTREE_GAP = 10 +export const AGENT_MAP_WORKTREE_GAP = 8 export const AGENT_MAP_PACKING_SCORE_TOLERANCE = 0.001 const PACKING_GRID_SIZE = 128 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 new file mode 100644 index 00000000000..413102bb3f0 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-project-placement.ts @@ -0,0 +1,50 @@ +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-spawn-clustering.ts b/src/renderer/src/components/dashboard-popout/agent-map-spawn-clustering.ts new file mode 100644 index 00000000000..a341fdd1767 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-spawn-clustering.ts @@ -0,0 +1,27 @@ +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-viewport-transition.ts b/src/renderer/src/components/dashboard-popout/agent-map-viewport-transition.ts new file mode 100644 index 00000000000..6fe03f6f585 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-viewport-transition.ts @@ -0,0 +1,56 @@ +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 index 8ae50f5e417..5ceaf4e93eb 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-workspace-identity.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-workspace-identity.ts @@ -1,4 +1,4 @@ -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' export function agentMapCardTopologyIdentity(card: DashboardCard): string { const parentPaneKey = card.parentPaneKey ?? '' @@ -7,6 +7,11 @@ export function agentMapCardTopologyIdentity(card: DashboardCard): string { 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'] @@ -18,3 +23,7 @@ export function agentMapWorktreeIdentityFromParts( 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 new file mode 100644 index 00000000000..1a9593af373 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts @@ -0,0 +1,81 @@ +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 new file mode 100644 index 00000000000..311dd95e2f6 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.ts @@ -0,0 +1,25 @@ +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-lineage-layout.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts index f3db9066e2f..8bfb9fc28d4 100644 --- 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 @@ -77,7 +77,7 @@ function layoutWithWorktreePushCount(count: number) { } describe('layoutAgentMapWorktreeLineage', () => { - it('preserves the legacy coordinates for branched and linear families', () => { + 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 }, @@ -92,36 +92,36 @@ describe('layoutAgentMapWorktreeLineage', () => { id: 'child-a', parentId: 'root', radius: 30, - x: -4.33242478827686, - y: 47.79022515386755 + x: -7.740689238053122, + y: 42.47172405948656 }, { id: 'child-b', parentId: 'root', radius: 45, - x: 79.07393510462774, - y: 194.80695213220156 + x: 69.93546272327787, + y: 175.54837055839306 }, { id: 'grandchild-a', parentId: 'child-a', radius: 25, - x: -4.33242478827686, - y: 144.79022515386754 + x: -7.740689238053122, + y: 125.47172405948656 }, - { id: 'root', radius: 40, x: 21.870755158175434, y: -64.20977484613245 }, + { id: 'root', radius: 40, x: 19.097386742612372, y: -55.52827594051344 }, { id: 'second-child', parentId: 'second-root', radius: 20, - x: -121.42967792073006, - y: -79.34629927692441 + x: -112.9872940755618, + y: -73.65876088400047 }, { id: 'second-root', radius: 35, - x: -121.42967792073006, - y: -176.3462992769244 + x: -112.9872940755618, + y: -156.65876088400046 } ]) }) @@ -201,4 +201,23 @@ describe('layoutAgentMapWorktreeLineage', () => { } 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 index d3acc3acec1..1a6e2875b65 100644 --- 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 @@ -1,11 +1,13 @@ import { AGENT_MAP_WORKTREE_GAP, packAgentMapWorktrees } from './agent-map-worktree-packing' -const LINEAGE_VERTICAL_GAP = 42 +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 @@ -219,10 +221,24 @@ function layoutBoundedLineage( 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 parentId = worktree.parentId + 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 } diff --git a/src/renderer/src/components/dashboard-popout/agent-map.css b/src/renderer/src/components/dashboard-popout/agent-map.css index d2be6fc9d75..db37aef12bc 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map.css +++ b/src/renderer/src/components/dashboard-popout/agent-map.css @@ -34,7 +34,7 @@ pointer-events: none; stroke: var(--background); stroke-linejoin: round; - stroke-width: 2px; + stroke-width: 4px; } .agent-map-project-label-frame { @@ -44,10 +44,13 @@ .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; @@ -57,6 +60,15 @@ 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); @@ -66,7 +78,7 @@ pointer-events: none; stroke: var(--background); stroke-linejoin: round; - stroke-width: 2px; + stroke-width: 4px; } .agent-map-project-count { @@ -125,8 +137,7 @@ } .agent-map-worktree-label-group.is-visible, -.agent-map-worktree-group:hover .agent-map-worktree-label-group, -.agent-map-worktree-group:focus-within .agent-map-worktree-label-group { +.agent-map-worktree-label-group.is-active { opacity: 1; } @@ -134,9 +145,7 @@ opacity: 0; } -.agent-map-worktree-label-group.is-count-visible .agent-map-worktree-count, -.agent-map-worktree-group:hover .agent-map-worktree-count, -.agent-map-worktree-group:focus-within .agent-map-worktree-count { +.agent-map-worktree-label-group.is-count-visible .agent-map-worktree-count { opacity: 1; } @@ -157,7 +166,8 @@ .agent-map-lineage-link { fill: none; pointer-events: none; - stroke: color-mix(in srgb, var(--muted-foreground) 38%, transparent); + stroke: color-mix(in srgb, var(--muted-foreground) 42%, transparent); + stroke-dasharray: 1 4; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1; @@ -192,14 +202,19 @@ } .agent-map-agent-node:hover .agent-map-agent-hit, -.agent-map-agent-node:focus-visible .agent-map-agent-hit, -.agent-map-agent-node.is-selected .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 { - stroke-width: 2; + 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 { @@ -227,7 +242,6 @@ pointer-events: none; stroke: var(--background); stroke-width: 2; - vector-effect: non-scaling-stroke; } .agent-map-aggregate-node circle { diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts b/src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts new file mode 100644 index 00000000000..c44843802f8 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts @@ -0,0 +1,35 @@ +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/useAgentMapSelectedFocus.ts b/src/renderer/src/components/dashboard-popout/useAgentMapSelectedFocus.ts new file mode 100644 index 00000000000..2f22f63747e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapSelectedFocus.ts @@ -0,0 +1,63 @@ +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 new file mode 100644 index 00000000000..91a8e6e1af1 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapViewportTransition.ts @@ -0,0 +1,51 @@ +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 6f9979a34b9..ea1cdb80063 100644 --- a/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx @@ -51,6 +51,9 @@ beforeEach(() => { mocks.useLiveDashboardSnapshot.mockClear() mocks.blockingOverlay = false mocks.boardProps = null + ;(window as unknown as { api: unknown }).api = { + dashboard: { openPopout: vi.fn().mockResolvedValue(undefined) } + } }) afterEach(() => { @@ -82,13 +85,23 @@ describe('AgentDashboardDrawer', () => { expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false) }) - it('enables workspace ring menus only while the in-window dashboard is mounted', () => { + it('hands map rendering to the dedicated popout', () => { + const openPopout = vi.mocked(window.api.dashboard.openPopout) render() expect(mocks.boardProps).toBeNull() act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) - expect(mocks.boardProps?.workspaceContextMenusEnabled).toBe(true) - expect(mocks.boardProps?.onWorkspaceContextMenuOpenChange).toBeTypeOf('function') + expect(mocks.boardProps?.initialView).toBe('board') + expect(mocks.boardProps?.workspaceContextMenusEnabled).toBeUndefined() + const onOpenMap = mocks.boardProps?.onOpenMap + expect(onOpenMap).toBeTypeOf('function') + + act(() => { + ;(onOpenMap as () => void)() + }) + + expect(openPopout).toHaveBeenCalledWith('map') + expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false) }) it('reveals a colliding worktree on the card execution host', () => { diff --git a/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx b/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx index 02f81b853ac..e9e0efcc13d 100644 --- a/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx @@ -62,6 +62,11 @@ function AgentDashboardDrawerBody({ void window.api.dashboard.openPopout?.() }, [onClose]) + const handleOpenMap = useCallback(() => { + onClose() + void window.api.dashboard.openPopout?.('map') + }, [onClose]) + return ( { + it('keeps all dashboard feature modules out of the disabled app and sidebar path', () => { + const app = source('App.tsx') + const sidebar = source('components/sidebar/index.tsx') + const nav = source('components/sidebar/SidebarNav.tsx') + + expect(app).not.toMatch(/from ['"].*DashboardPopoutBridge['"]/) + expect(app).toContain("import('./components/dashboard/DashboardPopoutBridge')") + expect(sidebar).not.toMatch(/from ['"].*AgentDashboard(?:Drawer|SidebarHost)['"]/) + expect(sidebar).toContain("import('./AgentDashboardSidebarHost')") + expect(nav).not.toContain('useAgentBucketCounts') + 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') + + expect(board).toContain("import('./AgentDashboardMapView')") + expect(board).not.toMatch(/from ['"].\/(?:AgentMap|useAgentMap|agent-map-)/) + expect(drawer).toContain('initialView="board"') + expect(drawer).toContain("openPopout?.('map')") + expect(drawer).toContain('onOpenMap={handleOpenMap}') + }) +}) diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts index 743036494c0..f5db7045970 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts @@ -116,6 +116,17 @@ describe('buildDashboardSnapshot folder workspaces', () => { expect(snapshot.filterOptions?.projects).toEqual([ { id: 'folder-workspace:group-1', label: 'Documentation' } ]) + expect(snapshot.workspaces).toEqual([ + expect.objectContaining({ + repoId: 'folder-workspace:group-1', + worktreeId: WORKSPACE_ID, + repoName: 'Documentation', + worktreeName: 'Docs workspace', + workspaceKind: 'folder', + hostKind: 'ssh', + executionHostId: 'ssh:ssh-1' + }) + ]) }) it('classifies a folder workspace from its own runtime host stamp', () => { diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts index 4c4bdbd726b..90630a3d924 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts @@ -145,6 +145,23 @@ describe('buildDashboardSnapshot', () => { ) expect(snapshot.cards).toEqual([]) + expect(snapshot.workspaces).toEqual([ + expect.objectContaining({ + repoId: 'r1', + worktreeId: 'w1', + repoName: 'Repo One', + worktreeName: 'wt-one', + hostKind: 'local', + executionHostId: 'local', + workspaceKind: 'worktree' + }), + expect.objectContaining({ + repoId: 'r2', + worktreeId: 'w2', + repoName: 'Repo Two', + worktreeName: 'wt-two' + }) + ]) expect(snapshot.filterOptions).toEqual({ projects: [ { id: 'r1', label: 'Repo One' }, @@ -579,6 +596,7 @@ describe('buildDashboardSnapshot', () => { // host-input resolution off the sidebar's per-status-tick rebuild. expect(snapshot.cards[0].ptyId).toBe('pty1') expect(snapshot.cards[0].terminalInput).toBeUndefined() + expect(snapshot.workspaces).toBeUndefined() expect(linkedReviewReads).toBe(0) expect(hostIdentityReads).toBe(0) expect(mapMetadataCalls.hostKind).not.toHaveBeenCalled() diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index 2bd9fb4b3c9..606445c532e 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -1,14 +1,14 @@ import type { AppState } from '@/store/types' import { + DASHBOARD_MAX_MAP_WORKSPACES, dashboardCardDisplayState, - type DashboardBucket, type DashboardCard, type DashboardCardDotState, type DashboardCardSubagent, - type DashboardSnapshot + type DashboardSnapshot, + type DashboardWorkspace } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' -import { DEFAULT_WORKSPACE_STATUSES } from '../../../../shared/workspace-statuses' import { parsePaneKey } from '../../../../shared/stable-pane-id' import { resolveDashboardCardTerminalInput, @@ -54,6 +54,8 @@ import { buildDashboardWorktreeLaunchOptions, type DashboardLaunchDetectionState } from './dashboard-worktree-launch-options' +import { buildDashboardSnapshotFilterOptions } from './dashboard-snapshot-filter-options' +import { dashboardBucketForDotState } from './dashboard-card-bucket' /** The store slices the snapshot builder reads. Kept as a Pick so unit tests * can pass a partial store without constructing the whole AppState. */ @@ -75,21 +77,6 @@ export type DashboardSnapshotState = Pick< DashboardCardContextState & Partial -function bucketForState(state: DashboardCardDotState): DashboardBucket { - switch (state) { - case 'working': - return 'working' - case 'done': - return 'done' - case 'idle': - return 'idle' - // blocked | waiting — the agent needs the user. - case 'blocked': - case 'waiting': - return 'attention' - } -} - /** * Derive the serializable dashboard snapshot from the live renderer store. * Reuses the exact per-worktree row machinery the sidebar uses @@ -103,6 +90,8 @@ export function buildDashboardSnapshot( options: { includeCardDetails?: boolean; includeFilterOptions?: boolean } = {} ): DashboardSnapshot { const cards: DashboardCard[] = [] + const workspaces: DashboardWorkspace[] | undefined = + options.includeCardDetails === false ? undefined : [] const clientHost = readDashboardClientHost() const repoIconsByRepoId: Record = {} const includeCardDetails = options.includeCardDetails !== false @@ -112,26 +101,7 @@ export function buildDashboardSnapshot( const filterOptions = options.includeFilterOptions === false ? undefined - : { - // Why: filterOptions is snapshot-level, so an over-long project label - // costs the WHOLE board, not one card. Bound it at the producer. - projects: [ - ...new Map( - activeWorktrees.map((workspace) => [workspace.projectId, workspace]) - ).values() - ].map((workspace) => ({ - id: workspace.projectId, - label: boundedLabel(workspace.projectName) - })), - workspaceStatuses: (state.workspaceStatuses && state.workspaceStatuses.length > 0 - ? state.workspaceStatuses - : DEFAULT_WORKSPACE_STATUSES - ).map((status) => ({ - id: status.id, - label: status.label, - color: status.color - })) - } + : buildDashboardSnapshotFilterOptions(state, activeWorktrees) let singletonOrchestration: ReturnType | null = null let orchestrationByWorktree: ReturnType | null = null @@ -214,6 +184,21 @@ export function buildDashboardSnapshot( const context = includeCardDetails ? resolveDashboardCardContext(state, repo, worktree) : undefined + if (workspaces && workspaces.length < DASHBOARD_MAX_MAP_WORKSPACES) { + workspaces.push({ + repoId: workspace.projectId, + worktreeId, + repoName: boundedLabel(workspace.projectName), + worktreeName: boundedLabel(worktree.displayName), + ...(parentWorktreeId ? { parentWorktreeId } : {}), + ...dashboardCardMapWorkspaceMetadata(workspace, null, undefined, clientHost.platform), + workspaceStatusId: context?.workspaceStatus.id, + workspaceStatusLabel: context?.workspaceStatus.label, + workspaceStatusColor: context?.workspaceStatus.color, + hasReview: context?.hasReview, + review: context?.review + }) + } for (const row of rows) { // Child rows have no pane of their own; the board lists top-level agents. @@ -242,7 +227,7 @@ export function buildDashboardSnapshot( const unseen = !isTitleDerived && (state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt - const bucket = bucketForState(dashboardCardDisplayState({ dotState, unseen })) + const bucket = dashboardBucketForDotState(dashboardCardDisplayState({ dotState, unseen })) // Why: only a live pty can open a preview terminal, and only a // card-rendering caller can open one — the sidebar's bucket counts must // not pay host resolution on every agent-status tick. @@ -260,6 +245,7 @@ export function buildDashboardSnapshot( osRelease: clientHost.osRelease }) : null + const finishedAt = lastEnteredDoneAt(row) // Only repos that actually contribute a card ship their icon. repoIconsByRepoId[workspace.projectId] = workspace.repoIcon @@ -297,7 +283,7 @@ export function buildDashboardSnapshot( lastUserMessage: isTitleDerived ? undefined : nonEmpty(row.entry.prompt), lastAgentMessage: isTitleDerived ? undefined : nonEmpty(row.entry.lastAssistantMessage), startedAt: row.startedAt, - finishedAt: lastEnteredDoneAt(row), + finishedAt, stateChangedAt: row.entry.stateStartedAt || row.startedAt, // Same derivation as WorktreeCardAgents' unvisitedByPaneKey, so the // board and the sidebar bold/mute the same agents at the same time. @@ -312,12 +298,19 @@ export function buildDashboardSnapshot( return { generatedAt: now, cards, + ...(workspaces ? { workspaces } : {}), showIdle, filterOptions, // Only the dashboard surfaces offer a launcher; the count-only rebuild that // feeds the sidebar must not pay for host-detection lookups. ...(includeCardDetails - ? { launchableAgentsByWorktreeId: buildDashboardWorktreeLaunchOptions(state, cards) } + ? { + launchableAgentsByWorktreeId: buildDashboardWorktreeLaunchOptions( + state, + cards, + workspaces + ) + } : {}), repoIconsByRepoId } diff --git a/src/renderer/src/components/dashboard/dashboard-card-bucket.ts b/src/renderer/src/components/dashboard/dashboard-card-bucket.ts new file mode 100644 index 00000000000..a81129a227c --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-card-bucket.ts @@ -0,0 +1,15 @@ +import type { DashboardBucket, DashboardCardDotState } from '../../../../shared/dashboard-snapshot' + +export function dashboardBucketForDotState(state: DashboardCardDotState): DashboardBucket { + switch (state) { + case 'working': + return 'working' + case 'done': + return 'done' + case 'idle': + return 'idle' + case 'blocked': + case 'waiting': + return 'attention' + } +} diff --git a/src/renderer/src/components/dashboard/dashboard-snapshot-filter-options.ts b/src/renderer/src/components/dashboard/dashboard-snapshot-filter-options.ts new file mode 100644 index 00000000000..a17776433a3 --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-snapshot-filter-options.ts @@ -0,0 +1,27 @@ +import type { AppState } from '@/store/types' +import type { DashboardFilterOptions } from '../../../../shared/dashboard-snapshot' +import { DEFAULT_WORKSPACE_STATUSES } from '../../../../shared/workspace-statuses' +import { boundedLabel } from './dashboard-card-labels' +import type { ActiveDashboardWorkspace } from './dashboard-snapshot-workspaces' + +export function buildDashboardSnapshotFilterOptions( + state: Partial>, + activeWorkspaces: ActiveDashboardWorkspace[] +): DashboardFilterOptions { + const projects = [ + ...new Map(activeWorkspaces.map((workspace) => [workspace.projectId, workspace])).values() + ].map((workspace) => ({ + id: workspace.projectId, + label: boundedLabel(workspace.projectName) + })) + const workspaceStatuses = ( + state.workspaceStatuses && state.workspaceStatuses.length > 0 + ? state.workspaceStatuses + : DEFAULT_WORKSPACE_STATUSES + ).map((status) => ({ + id: status.id, + label: status.label, + color: status.color + })) + return { projects, workspaceStatuses } +} diff --git a/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts b/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts index 99d35189bdc..a83619511f8 100644 --- a/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts +++ b/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts @@ -6,7 +6,7 @@ import type { DashboardCardWorkspaceKind } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' -import { getWorktreeExecutionHostId } from '../../../../shared/execution-host' +import { getWorktreeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' import { isFolderRepo } from '../../../../shared/repo-kind' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' @@ -111,7 +111,11 @@ export function dashboardCardMapWorkspaceMetadata( ptyId: string | null, terminalInput: DashboardCard['terminalInput'], clientPlatform: NodeJS.Platform -): Pick { +): { + hostKind: DashboardCardHostKind + executionHostId: ExecutionHostId + workspaceKind: DashboardCardWorkspaceKind +} { return { hostKind: dashboardCardHostKind(workspace, ptyId, terminalInput, clientPlatform), executionHostId: getWorktreeExecutionHostId(workspace.worktree, workspace.repo ?? undefined), diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts index 5c6f6adf7c2..e7c0c04d85c 100644 --- a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts +++ b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { DASHBOARD_MAX_LAUNCH_WORKTREES } from '../../../../shared/dashboard-snapshot' -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { buildDashboardWorktreeLaunchOptions } from './dashboard-worktree-launch-options' @@ -71,6 +71,25 @@ describe('buildDashboardWorktreeLaunchOptions', () => { expect(options).toEqual({ 'worktree-1': ['codex', 'gemini'] }) }) + it('publishes detected launch choices for workspaces without cards', () => { + const workspace: DashboardWorkspace = { + repoId: 'repo-1', + worktreeId: 'empty-worktree', + repoName: 'Orca', + worktreeName: 'Empty', + hostKind: 'local', + executionHostId: 'local', + workspaceKind: 'worktree' + } + const options = buildDashboardWorktreeLaunchOptions( + state({ detectedAgentIds: ['claude', 'codex'] }), + [], + [workspace] + ) + + expect(options).toEqual({ 'empty-worktree': ['claude', 'codex'] }) + }) + it('uses each git workspace execution host instead of local detection', () => { const options = buildDashboardWorktreeLaunchOptions( state({ diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts index 27f254790fb..16ca6c1f795 100644 --- a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts +++ b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts @@ -2,7 +2,8 @@ import type { AppState } from '@/store/types' import { parseExecutionHostId } from '../../../../shared/execution-host' import { DASHBOARD_MAX_LAUNCH_WORKTREES, - type DashboardCard + type DashboardCard, + type DashboardWorkspace } from '../../../../shared/dashboard-snapshot' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { @@ -78,11 +79,16 @@ function detectedAgentsForWorktree( /** Host-detected choices plus providers already proven to run in the workspace. */ export function buildDashboardWorktreeLaunchOptions( state: DashboardLaunchOptionState, - cards: readonly DashboardCard[] + cards: readonly DashboardCard[], + workspaces: readonly DashboardWorkspace[] = [] ): Record { const catalog = buildDashboardLaunchCatalog(state) const cardsByWorktreeId = new Map() + const repoIdByWorktreeId = new Map( + workspaces.map((workspace) => [workspace.worktreeId, workspace.repoId]) + ) for (const card of cards) { + repoIdByWorktreeId.set(card.worktreeId, card.repoId) const existing = cardsByWorktreeId.get(card.worktreeId) if (existing) { existing.push(card) @@ -92,14 +98,15 @@ export function buildDashboardWorktreeLaunchOptions( } const result: Record = {} - for (const [worktreeId, worktreeCards] of cardsByWorktreeId) { + for (const [worktreeId, repoId] of repoIdByWorktreeId) { // Past the validator's bound the snapshot itself would be rejected, so the // launcher goes quiet for the tail rather than taking the board down. if (Object.keys(result).length >= DASHBOARD_MAX_LAUNCH_WORKTREES) { break } + const worktreeCards = cardsByWorktreeId.get(worktreeId) ?? [] const available = new Set( - detectedAgentsForWorktree(state, worktreeId, worktreeCards[0].repoId, catalog) + detectedAgentsForWorktree(state, worktreeId, repoId, catalog) ) for (const card of worktreeCards) { if (isTuiAgent(card.agentType)) { diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index 852f1d84337..5a767f830f4 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -138,7 +138,7 @@ describe('ExperimentalPane', () => { ) - expect(settings.experimentalAgentDashboardPopout).toBe(false) + expect(settings.experimentalAgentDashboardPopout).toBeUndefined() expect(markup).toContain('Agent Dashboard') expect(markup).toContain('Monitor agents that need you, are working, or are done') expect(getExperimentalPaneSearchEntries().map((entry) => entry.title)).toContain( diff --git a/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx b/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx new file mode 100644 index 00000000000..5eeae6f145c --- /dev/null +++ b/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx @@ -0,0 +1,90 @@ +import { LayoutDashboard, MessageCircleQuestion } from 'lucide-react' +import { useAppStore } from '@/store' +import { cn } from '@/lib/utils' +import { DASHBOARD_BUCKET_ORDER, type DashboardBucket } from '../../../../shared/dashboard-snapshot' +import { useAgentBucketCounts } from '@/components/dashboard/useAgentBucketCounts' +import { translate } from '@/i18n/i18n' + +const DASHBOARD_BUCKET_DOT_CLASS: Record<'working' | 'done' | 'idle', string> = { + working: 'bg-yellow-500', + done: 'bg-emerald-500', + idle: 'bg-neutral-500/50' +} + +function dashboardBucketLabel(bucket: DashboardBucket): string { + switch (bucket) { + 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 DashboardBucketCounts({ + counts, + showIdle +}: { + counts: Record + showIdle: boolean +}): React.JSX.Element | null { + const active = DASHBOARD_BUCKET_ORDER.filter( + (bucket) => counts[bucket] > 0 && (bucket !== 'idle' || showIdle) + ) + if (active.length === 0) { + return null + } + return ( + + {active.map((bucket) => ( + + {bucket === 'attention' ? ( + + ) : ( + + )} + {counts[bucket]} + + ))} + + ) +} + +export default function AgentDashboardSidebarEntry(): React.JSX.Element { + const dashboardBucketCounts = useAgentBucketCounts() + const showIdle = useAppStore((s) => s.settings?.experimentalAgentDashboardShowIdle === true) + const openAsPopout = useAppStore((s) => s.settings?.experimentalAgentDashboardMode === 'popout') + const drawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen) + const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen) + + return ( + + ) +} diff --git a/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx b/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx new file mode 100644 index 00000000000..1b90f264c3e --- /dev/null +++ b/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import AgentDashboardSidebarHost from './AgentDashboardSidebarHost' + +vi.mock('@/components/dashboard/AgentDashboardDrawer', () => ({ + AgentDashboardDrawer: () => null +})) + +const initialState = useAppStore.getInitialState() + +beforeEach(() => { + useAppStore.setState({ agentDashboardDrawerOpen: false }, false) +}) + +afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) +}) + +describe('AgentDashboardSidebarHost', () => { + it('closes the dashboard when the workspace board opens', async () => { + useAppStore.setState({ agentDashboardDrawerOpen: true }) + render( + + ) + + await waitFor(() => expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false)) + }) + + it('closes the workspace board when the dashboard opens', async () => { + const closeWorkspaceBoard = vi.fn() + const view = render( + + ) + + act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) + view.rerender( + + ) + + await waitFor(() => expect(closeWorkspaceBoard).toHaveBeenCalledOnce()) + }) + + it('clears an open dashboard when the sidebar closes', async () => { + useAppStore.setState({ agentDashboardDrawerOpen: true }) + render( + + ) + + await waitFor(() => expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false)) + }) +}) diff --git a/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.tsx b/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.tsx new file mode 100644 index 00000000000..222571dc0be --- /dev/null +++ b/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.tsx @@ -0,0 +1,43 @@ +import { useEffect } from 'react' +import { useAppStore } from '@/store' +import { AgentDashboardDrawer } from '@/components/dashboard/AgentDashboardDrawer' + +type AgentDashboardSidebarHostProps = { + sidebarOpen: boolean + workspaceBoardOpen: boolean + closeWorkspaceBoard: () => void + leftSidebarStyle?: React.CSSProperties + statusBarVisible: boolean +} + +/** Opt-in dashboard coordination stays outside the normal sidebar path. */ +export default function AgentDashboardSidebarHost({ + sidebarOpen, + workspaceBoardOpen, + closeWorkspaceBoard, + leftSidebarStyle, + statusBarVisible +}: AgentDashboardSidebarHostProps): React.JSX.Element | null { + const drawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen) + const setDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen) + + useEffect(() => { + if (!sidebarOpen && drawerOpen) { + setDrawerOpen(false) + } + }, [drawerOpen, setDrawerOpen, sidebarOpen]) + useEffect(() => { + if (drawerOpen) { + closeWorkspaceBoard() + } + }, [closeWorkspaceBoard, drawerOpen]) + useEffect(() => { + if (workspaceBoardOpen) { + setDrawerOpen(false) + } + }, [setDrawerOpen, workspaceBoardOpen]) + + return sidebarOpen ? ( + + ) : null +} diff --git a/src/renderer/src/components/sidebar/Sidebar.test.tsx b/src/renderer/src/components/sidebar/Sidebar.test.tsx index 36db65daeb1..6704020676b 100644 --- a/src/renderer/src/components/sidebar/Sidebar.test.tsx +++ b/src/renderer/src/components/sidebar/Sidebar.test.tsx @@ -219,63 +219,4 @@ describe('Sidebar', () => { expect(fetchAllWorktrees).not.toHaveBeenCalled() }) - - describe('companion board mutual exclusion', () => { - function renderWithDashboardOpen(): { - view: ReturnType - setAgentDashboardDrawerOpen: ReturnType - } { - setSidebarState(getDefaultSettings(tmpdir())) - const setAgentDashboardDrawerOpen = vi.fn() - mocks.state = { ...mocks.state, agentDashboardDrawerOpen: true, setAgentDashboardDrawerOpen } - mocks.panel = { - workspaceBoardOpen: false, - workspaceBoardRenderedOpen: false, - workspaceBoardDragPreviewOpen: false - } - return { view: render(sidebarElement()), setAgentDashboardDrawerOpen } - } - - it('keeps the agent dashboard open while a worktree drag only previews the board', () => { - const { view, setAgentDashboardDrawerOpen } = renderWithDashboardOpen() - - mocks.panel = { - workspaceBoardOpen: false, - workspaceBoardRenderedOpen: true, - workspaceBoardDragPreviewOpen: true - } - view.rerender(sidebarElement()) - - expect(setAgentDashboardDrawerOpen).not.toHaveBeenCalled() - }) - - it('closes the agent dashboard when the workspace board is actually opened', () => { - const { view, setAgentDashboardDrawerOpen } = renderWithDashboardOpen() - - mocks.panel = { - workspaceBoardOpen: true, - workspaceBoardRenderedOpen: true, - workspaceBoardDragPreviewOpen: false - } - view.rerender(sidebarElement()) - - expect(setAgentDashboardDrawerOpen).toHaveBeenCalledWith(false) - }) - - it('closes the workspace board when the agent dashboard opens', () => { - setSidebarState(getDefaultSettings(tmpdir())) - mocks.panel = { - workspaceBoardOpen: true, - workspaceBoardRenderedOpen: true, - workspaceBoardDragPreviewOpen: false - } - const view = render(sidebarElement()) - mocks.closeWorkspaceBoard.mockClear() - - mocks.state = { ...mocks.state, agentDashboardDrawerOpen: true } - view.rerender(sidebarElement()) - - expect(mocks.closeWorkspaceBoard).toHaveBeenCalled() - }) - }) }) diff --git a/src/renderer/src/components/sidebar/SidebarNav.test.tsx b/src/renderer/src/components/sidebar/SidebarNav.test.tsx index 7d041bfb264..9d72ab4d489 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.test.tsx @@ -2,6 +2,7 @@ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' +import { waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import { getDefaultSettings } from '../../../../shared/constants' @@ -21,6 +22,7 @@ const mocks = vi.hoisted(() => ({ checkLinearConnection: vi.fn(), hasPairedMobileDevice: false, agentBucketCounts: { attention: 0, working: 0, done: 0, idle: 0 }, + getAgentBucketCounts: vi.fn(), dismissMobileOnboardingBadge: vi.fn(), setSetupGuideSidebarDismissed: vi.fn() })) @@ -41,7 +43,10 @@ vi.mock('@/components/activity/useActivityUnreadCount', () => ({ })) vi.mock('@/components/dashboard/useAgentBucketCounts', () => ({ - useAgentBucketCounts: () => mocks.agentBucketCounts + useAgentBucketCounts: () => { + mocks.getAgentBucketCounts() + return mocks.agentBucketCounts + } })) vi.mock('@/hooks/useShortcutLabel', () => ({ @@ -243,6 +248,7 @@ describe('SidebarNav', () => { const container = await renderSidebarNav() expect(queryButtonByText(container, 'Agent Dashboard')).toBeNull() + expect(mocks.getAgentBucketCounts).not.toHaveBeenCalled() }) it('mounts the Agent Dashboard row after opt-in', async () => { @@ -254,7 +260,8 @@ describe('SidebarNav', () => { }) const container = await renderSidebarNav() - expect(queryButtonByText(container, 'Agent Dashboard')).not.toBeNull() + await waitFor(() => expect(queryButtonByText(container, 'Agent Dashboard')).not.toBeNull()) + expect(mocks.getAgentBucketCounts).toHaveBeenCalledTimes(1) }) it('uses a question glyph only for the Needs You count', async () => { @@ -268,6 +275,9 @@ describe('SidebarNav', () => { }) const container = await renderSidebarNav() + await waitFor(() => + expect(container.querySelector('[aria-label="Needs You: 2"]')).not.toBeNull() + ) const attention = container.querySelector('[aria-label="Needs You: 2"]') const working = container.querySelector('[aria-label="Working: 3"]') const done = container.querySelector('[aria-label="Done: 1"]') diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index d2a36fb79e4..b14295d3714 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -1,19 +1,9 @@ import React from 'react' -import { - Bell, - CalendarClock, - EyeOff, - LayoutDashboard, - MessageCircleQuestion, - Search, - Smartphone -} from 'lucide-react' +import { Bell, CalendarClock, EyeOff, Search, Smartphone } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import type { GlobalSettings } from '../../../../shared/types' -import { DASHBOARD_BUCKET_ORDER, type DashboardBucket } from '../../../../shared/dashboard-snapshot' -import { useAgentBucketCounts } from '@/components/dashboard/useAgentBucketCounts' import { useActivityUnreadCount } from '@/components/activity/useActivityUnreadCount' import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' @@ -25,6 +15,7 @@ import { SetupGuideSidebarEntry } from './SetupGuideSidebarEntry' import { SidebarTaskNavButton } from './SidebarTaskNavButton' import { HideSidebarMenu } from './sidebar-nav-controls' import { translate } from '@/i18n/i18n' +import { lazyWithRetry } from '@/lib/lazy-with-retry' export { getSetupGuideSidebarEntryReady, shouldShowSetupGuideEntry } from './SetupGuideSidebarEntry' @@ -40,14 +31,6 @@ export function shouldShowAgentDashboardButton( return settings?.experimentalAgentDashboardPopout === true } -// Why: in-window is the default surface; only an explicit 'popout' choice opens -// the separate OS window. -function isAgentDashboardPopoutMode( - settings: Pick | null | undefined -): boolean { - return settings?.experimentalAgentDashboardMode === 'popout' -} - export function shouldShowMobileButton( settings: Pick | null | undefined ): boolean { @@ -60,93 +43,7 @@ export function shouldShowAutomationsButton( return settings?.showAutomationsButton !== false } -const DASHBOARD_BUCKET_DOT_CLASS: Record<'working' | 'done' | 'idle', string> = { - working: 'bg-yellow-500', - done: 'bg-emerald-500', - idle: 'bg-neutral-500/50' -} - -function dashboardBucketLabel(bucket: DashboardBucket): string { - switch (bucket) { - 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 DashboardBucketCounts({ - counts, - showIdle -}: { - counts: Record - showIdle: boolean -}): React.JSX.Element | null { - const active = DASHBOARD_BUCKET_ORDER.filter( - (bucket) => counts[bucket] > 0 && (bucket !== 'idle' || showIdle) - ) - if (active.length === 0) { - return null - } - return ( - - {active.map((bucket) => ( - - {bucket === 'attention' ? ( - - ) : ( - - )} - {counts[bucket]} - - ))} - - ) -} - -// Why: keep the dashboard's broad aggregate subscriptions out of SidebarNav so -// agent-status churn only updates this opt-in row, not the full navigation. -function AgentDashboardSidebarEntry(): React.JSX.Element { - const dashboardBucketCounts = useAgentBucketCounts() - const showIdle = useAppStore((s) => s.settings?.experimentalAgentDashboardShowIdle === true) - const openAsPopout = useAppStore((s) => isAgentDashboardPopoutMode(s.settings)) - const drawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen) - const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen) - - return ( - - ) -} +const AgentDashboardSidebarEntry = lazyWithRetry(() => import('./AgentDashboardSidebarEntry')) const SidebarNav = React.memo(function SidebarNav() { // Why: this memo boundary needs its own language subscription, while @@ -159,8 +56,13 @@ const SidebarNav = React.memo(function SidebarNav() { const openModal = useAppStore((s) => s.openModal) const updateSettings = useAppStore((s) => s.updateSettings) const activeView = useAppStore((s) => s.activeView) - const showAgentsButton = useAppStore((s) => shouldShowAgentsButton(s.settings)) - const showAgentDashboardButton = useAppStore((s) => shouldShowAgentDashboardButton(s.settings)) + const experimentalSidebarButtons = useAppStore( + (s) => + (shouldShowAgentsButton(s.settings) ? 1 : 0) | + (shouldShowAgentDashboardButton(s.settings) ? 2 : 0) + ) + const showAgentsButton = (experimentalSidebarButtons & 1) !== 0 + const showAgentDashboardButton = (experimentalSidebarButtons & 2) !== 0 const showAutomationsButton = useAppStore((s) => shouldShowAutomationsButton(s.settings)) const showMobileButton = useAppStore((s) => shouldShowMobileButton(s.settings)) const automationsActive = activeView === 'automations' @@ -211,7 +113,11 @@ const SidebarNav = React.memo(function SidebarNav() { ) : null} - {showAgentDashboardButton ? : null} + {showAgentDashboardButton ? ( + + + + ) : null} {showAgentsButton ? (