fix(agent-map): isolate map work from main renderer

This commit is contained in:
Brennan Benson
2026-08-05 18:19:32 -07:00
parent 63173ff12e
commit 2a6552f27f
68 changed files with 2402 additions and 754 deletions
@@ -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<string, unknown>
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<string, unknown>
return (
isDashboardFilterOptionList(options.projects) &&
isDashboardFilterOptionList(options.workspaceStatuses)
)
}
@@ -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)
+13 -34
View File
@@ -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<string, unknown>
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<string, unknown>
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<string, unknown>
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 `<img src>`, so each one must survive the
* same sanitizer the settings picker writes through. */
function isDashboardRepoIcons(value: unknown): boolean {
+9
View File
@@ -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', () => {
+5 -2
View File
@@ -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()
})
})
@@ -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<string, unknown>
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<string, unknown>
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)
}
@@ -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)
+7 -2
View File
@@ -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
}
+2 -1
View File
@@ -2497,7 +2497,7 @@ export type PreloadApi = {
) => Promise<OnboardingState>
}
dashboard: {
openPopout: () => Promise<void>
openPopout: (view?: 'board' | 'map') => Promise<void>
publishSnapshot: (snapshot: DashboardSnapshot) => Promise<void>
getPopoutOpen: () => Promise<boolean>
onPopoutOpenChanged: (callback: (open: boolean) => void) => () => void
@@ -2508,6 +2508,7 @@ export type PreloadApi = {
onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void) => () => void
requestSnapshot: () => Promise<void>
onSnapshot: (callback: (snapshot: DashboardSnapshot) => void) => () => void
onViewRequested: (callback: (view: 'board' | 'map') => void) => () => void
revealAgent: (args: DashboardRevealAgentArgs) => Promise<void>
ackAgent: (paneKey: string) => Promise<void>
spawnAgent: (args: DashboardSpawnAgentArgs) => Promise<void>
+8 -1
View File
@@ -2282,7 +2282,8 @@ const api = {
dashboard: {
// Open the pop-out dashboard window, or focus it if already open.
openPopout: (): Promise<void> => ipcRenderer.invoke('dashboardPopout:open'),
openPopout: (view?: 'board' | 'map'): Promise<void> =>
ipcRenderer.invoke('dashboardPopout:open', view),
// ── Producer side (main window) ──────────────────────────────────────
publishSnapshot: (snapshot: DashboardSnapshot): Promise<void> =>
@@ -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<void> =>
ipcRenderer.invoke('dashboardPopout:revealAgent', args),
ackAgent: (paneKey: string): Promise<void> =>
+6 -3
View File
@@ -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 {
<MacosTccPromptNoticeHost />
{/* Why: leaf-mounted retention sync keeps agent-status subscriptions out of the App render tree. */}
<RetainedAgentsSyncGate />
{settings?.experimentalAgentDashboardPopout === true ? (
<Suspense fallback={null}>
<DashboardPopoutBridge />
</Suspense>
) : null}
<AgentHibernationGate />
{/* Why: workspace activation is a hot path; activeWorktreeId in reset keys would remount whole surfaces during wake. */}
<RecoverableRenderErrorBoundary
@@ -34,18 +34,22 @@ export function AgentDashboardFilterChips({
projects,
statuses,
reviewLabel,
showAgentlessWorkspaces,
onProjectToggle,
onStatusToggle,
onReviewToggle,
onAgentlessWorkspacesToggle,
onClear
}: {
filters: DashboardFilters
projects: FilterLabel[]
statuses: FilterLabel[]
reviewLabel: (id: DashboardReviewFilter) => 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 ? (
<ActiveChip
label={translate(
'dashboardPopout.map.filters.agentlessWorkspaces',
'Workspaces without agents'
)}
onRemove={onAgentlessWorkspacesToggle}
/>
) : null}
<Button
variant="link"
size="xs"
@@ -0,0 +1,129 @@
import { Suspense, useMemo, useState, type RefObject } from 'react'
import type {
DashboardCard,
DashboardSleepWorkspaceArgs,
DashboardSnapshot,
DashboardSpawnAgentArgs
} from '../../../../shared/dashboard-snapshot'
import { cn } from '@/lib/utils'
import { lazyWithRetry } from '@/lib/lazy-with-retry'
import { AgentDashboardToolbar } from './AgentDashboardToolbar'
import { AgentTerminalPanel, type AgentRevealArgs } from './AgentTerminalDialog'
import {
EMPTY_DASHBOARD_FILTERS,
filterDashboardWorkspaces,
type DashboardFilters
} from './agent-board-filtering'
import { selectAgentlessMapWorkspaces } from './agent-map-workspace-visibility'
import { useAgentMapStateFilter } from './useAgentMapStateFilter'
const AgentMap = lazyWithRetry(
() => import('./AgentMap').then((module) => ({ default: module.AgentMap })),
{ reloadKey: 'agent-map' }
)
type AgentDashboardMapViewProps = {
snapshot: DashboardSnapshot
cards: DashboardCard[]
query: string
onQueryChange: (query: string) => void
filters: DashboardFilters
onFiltersChange: (filters: DashboardFilters) => void
searchInputRef: RefObject<HTMLInputElement | null>
now: number
dialogCard: DashboardCard | null
onDialogOpenChange: (open: boolean) => void
onRevealAgent: (args: AgentRevealArgs) => void
onOpenTerminal: (card: DashboardCard) => void
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void
workspaceContextMenusEnabled: boolean
onWorkspaceContextMenuOpenChange?: (open: boolean) => void
}
/** Map-only state and derivation stay in the pop-out's lazy chunk. */
export function AgentDashboardMapView({
snapshot,
cards,
query,
onQueryChange,
filters,
onFiltersChange,
searchInputRef,
now,
dialogCard,
onDialogOpenChange,
onRevealAgent,
onOpenTerminal,
onSpawnAgent,
onSleepWorkspace,
workspaceContextMenusEnabled,
onWorkspaceContextMenuOpenChange
}: AgentDashboardMapViewProps): React.JSX.Element {
const { agentStates, toggleAgentState, resetAgentStates } = useAgentMapStateFilter()
const [showAgentlessWorkspaces, setShowAgentlessWorkspaces] = useState(false)
const agentlessWorkspaces = useMemo(
() =>
selectAgentlessMapWorkspaces({
cards: snapshot.cards,
workspaces: snapshot.workspaces ?? [],
query: '',
filters: EMPTY_DASHBOARD_FILTERS
}),
[snapshot.cards, snapshot.workspaces]
)
const visibleAgentlessWorkspaces = useMemo(
() =>
showAgentlessWorkspaces ? filterDashboardWorkspaces(agentlessWorkspaces, query, filters) : [],
[agentlessWorkspaces, filters, query, showAgentlessWorkspaces]
)
return (
<>
<AgentDashboardToolbar
cards={snapshot.cards}
filterOptions={snapshot.filterOptions}
filteredCount={cards.length}
query={query}
onQueryChange={onQueryChange}
filters={filters}
onFiltersChange={onFiltersChange}
agentStates={agentStates}
onAgentStateToggle={toggleAgentState}
onAgentStatesReset={resetAgentStates}
showAgentlessWorkspaces={showAgentlessWorkspaces}
agentlessWorkspaceCount={agentlessWorkspaces.length}
onShowAgentlessWorkspacesChange={setShowAgentlessWorkspaces}
searchInputRef={searchInputRef}
/>
<div className={cn('flex min-h-0 flex-1', dialogCard && 'flex-row-reverse')}>
<Suspense fallback={null}>
<AgentMap
cards={cards}
workspaces={visibleAgentlessWorkspaces}
repoIconsByRepoId={snapshot.repoIconsByRepoId}
now={now}
className={dialogCard ? 'w-1/2 flex-none' : undefined}
compact={dialogCard !== null}
selectedPaneKey={dialogCard?.paneKey}
enabledStates={agentStates}
launchableAgentsByWorktreeId={snapshot.launchableAgentsByWorktreeId}
workspaceContextMenusEnabled={workspaceContextMenusEnabled}
onWorkspaceContextMenuOpenChange={onWorkspaceContextMenuOpenChange}
onOpenTerminal={onOpenTerminal}
onSpawnAgent={onSpawnAgent}
onSleepWorkspace={onSleepWorkspace}
/>
</Suspense>
{dialogCard ? (
<AgentTerminalPanel
card={dialogCard}
onOpenChange={onDialogOpenChange}
onReveal={onRevealAgent}
className="mr-0 animate-in fade-in-0 slide-in-from-left-2 duration-200 motion-reduce:animate-none"
/>
) : null}
</div>
</>
)
}
@@ -43,6 +43,9 @@ type AgentDashboardToolbarProps = {
agentStates?: ReadonlySet<AgentMapState>
onAgentStateToggle?: (state: AgentMapState) => void
onAgentStatesReset?: () => void
showAgentlessWorkspaces?: boolean
agentlessWorkspaceCount?: number
onShowAgentlessWorkspacesChange?: (show: boolean) => void
searchInputRef: React.RefObject<HTMLInputElement | null>
}
@@ -169,6 +172,9 @@ export function AgentDashboardToolbar({
agentStates,
onAgentStateToggle,
onAgentStatesReset,
showAgentlessWorkspaces,
agentlessWorkspaceCount = 0,
onShowAgentlessWorkspacesChange,
searchInputRef
}: AgentDashboardToolbarProps): React.JSX.Element {
const isMac = navigator.userAgent.includes('Mac')
@@ -181,7 +187,10 @@ export function AgentDashboardToolbar({
const agentStateCounts = agentStates ? countAgentMapCards(cards) : null
// A muted state is an active filter, so the badge counts them like the rest.
const mutedStateCount = agentStates ? AGENT_STATE_ROWS.length - agentStates.size : 0
const activeCount = activeDashboardFilterCount(filters) + mutedStateCount
const activeCount =
activeDashboardFilterCount(filters) +
mutedStateCount +
(showAgentlessWorkspaces === true ? 1 : 0)
const toggleProject = (id: string): void =>
onFiltersChange({ ...filters, projects: toggleDashboardFilter(filters.projects, id) })
const toggleStatus = (id: string): void =>
@@ -197,6 +206,7 @@ export function AgentDashboardToolbar({
const clearFilters = (): void => {
onFiltersChange({ projects: [], workspaceStatuses: [], reviewStates: [] })
onAgentStatesReset?.()
onShowAgentlessWorkspacesChange?.(false)
}
const reviewLabel = (id: DashboardReviewFilter): string =>
translate('dashboardPopout.filters.reviewChip', 'Review: {{state}}', {
@@ -264,6 +274,27 @@ export function AgentDashboardToolbar({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64" sideOffset={6}>
{showAgentlessWorkspaces !== undefined && onShowAgentlessWorkspacesChange ? (
<>
<DropdownMenuLabel>
{translate('dashboardPopout.map.filters.workspaceVisibility', 'Map content')}
</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={showAgentlessWorkspaces}
onCheckedChange={(checked) => onShowAgentlessWorkspacesChange(checked === true)}
onSelect={(event) => event.preventDefault()}
>
<span className="truncate">
{translate(
'dashboardPopout.map.filters.agentlessWorkspaces',
'Workspaces without agents'
)}
</span>
<OptionCount count={agentlessWorkspaceCount} />
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
</>
) : null}
{agentStates && agentStateCounts ? (
<>
<DropdownMenuLabel>
@@ -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}
@@ -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>): DashboardCard {
}
}
function workspace(overrides: Partial<DashboardWorkspace> = {}): 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<string, RepoIcon | null>
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(<AgentKanbanBoard snapshot={{ generatedAt: 1, cards: [agent] }} initialView="map" />)
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(<AgentKanbanBoard snapshot={{ generatedAt: 1, cards: [agent] }} initialView="map" />)
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', () => {
<AgentKanbanBoard snapshot={{ generatedAt: 1, cards: [fresh] }} initialView="map" />
)
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')
@@ -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<HTMLInputElement>(null)
const [filters, setFilters] = useState<DashboardFilters>(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({
</div>
) : null}
</div>
<AgentDashboardToolbar
cards={visibleCards}
filterOptions={snapshot.filterOptions}
filteredCount={filteredCards.length}
query={query}
onQueryChange={setQuery}
filters={filters}
onFiltersChange={setFilters}
agentStates={view === 'map' ? agentStates : undefined}
onAgentStateToggle={toggleAgentState}
onAgentStatesReset={resetAgentStates}
searchInputRef={searchInputRef}
/>
{view !== 'board' ? (
<div
className={cn(
'flex min-h-0 flex-1',
dialogCard && terminalPanelSide === 'left' && 'flex-row-reverse'
)}
>
<Suspense fallback={null}>
<AgentMap
cards={filteredCards}
repoIconsByRepoId={snapshot.repoIconsByRepoId}
now={now}
className={
dialogCard
? 'w-[clamp(14rem,28vw,22rem)] flex-none transition-[width] duration-200 motion-reduce:transition-none'
: undefined
}
compact={dialogCard !== null}
selectedPaneKey={dialogCard?.paneKey}
enabledStates={agentStates}
launchableAgentsByWorktreeId={snapshot.launchableAgentsByWorktreeId}
workspaceContextMenusEnabled={workspaceContextMenusEnabled}
onWorkspaceContextMenuOpenChange={onWorkspaceContextMenuOpenChange}
onOpenTerminal={handleOpenAdjacentTerminal}
onSpawnAgent={onSpawnAgent}
onSleepWorkspace={onSleepWorkspace}
/>
</Suspense>
{dialogCard ? (
<AgentTerminalPanel
card={dialogCard}
onOpenChange={handleDialogOpenChange}
onReveal={onRevealAgent}
className={cn(
terminalPanelSide === 'right' ? 'ml-0' : 'mr-0',
'animate-in fade-in-0 duration-200 motion-reduce:animate-none',
terminalPanelSide === 'right' ? 'slide-in-from-right-2' : 'slide-in-from-left-2'
)}
/>
) : null}
</div>
<Suspense fallback={null}>
<AgentDashboardMapView
snapshot={snapshot}
cards={filteredCards}
query={query}
onQueryChange={setQuery}
filters={filters}
onFiltersChange={setFilters}
searchInputRef={searchInputRef}
now={now}
dialogCard={dialogCard}
onDialogOpenChange={handleDialogOpenChange}
onRevealAgent={onRevealAgent}
onOpenTerminal={handleOpenTerminal}
onSpawnAgent={onSpawnAgent}
onSleepWorkspace={onSleepWorkspace}
workspaceContextMenusEnabled={workspaceContextMenusEnabled}
onWorkspaceContextMenuOpenChange={onWorkspaceContextMenuOpenChange}
/>
</Suspense>
) : (
<div className="scrollbar-sleek flex min-h-0 flex-1 overflow-x-auto p-3">
{/* Auto margins center the capped board and collapse during horizontal overflow. */}
<div className="mx-auto flex w-full max-w-[1280px] gap-3">
{visibleBuckets.map((bucket) => (
<KanbanColumn
key={bucket}
bucket={bucket}
cards={grouped[bucket]}
repoIconsByRepoId={snapshot.repoIconsByRepoId}
now={now}
onOpenTerminal={handleOpenTerminal}
/>
))}
<>
<AgentDashboardToolbar
cards={visibleCards}
filterOptions={snapshot.filterOptions}
filteredCount={filteredCards.length}
query={query}
onQueryChange={setQuery}
filters={filters}
onFiltersChange={setFilters}
searchInputRef={searchInputRef}
/>
<div className="scrollbar-sleek flex min-h-0 flex-1 overflow-x-auto p-3">
{/* Auto margins center the capped board and collapse during horizontal overflow. */}
<div className="mx-auto flex w-full max-w-[1280px] gap-3">
{visibleBuckets.map((bucket) => (
<KanbanColumn
key={bucket}
bucket={bucket}
cards={grouped[bucket]}
repoIconsByRepoId={snapshot.repoIconsByRepoId}
now={now}
onOpenTerminal={handleOpenTerminal}
/>
))}
</div>
</div>
</div>
</>
)}
{view === 'board' ? (
<AgentTerminalDialog
@@ -10,6 +10,7 @@ import type {
} from '../../../../shared/dashboard-snapshot'
import type { TuiAgent } from '../../../../shared/types'
import { AgentMap } from './AgentMap'
import { agentMapAttentionMarkerScale } from './AgentMapWorktreeRingNode'
import type { AgentMapState } from './agent-map-filter'
import { AGENT_MAP_AGENT_RADIUS } from './agent-map-layout'
@@ -52,7 +53,7 @@ function renderMap(
onSpawnAgent,
onSleepWorkspace
}: {
onOpenTerminal?: (card: DashboardCard, side: 'left' | 'right') => 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<SVGCircleElement>('[data-agent-unread-marker]')!
const agentMark =
marker.parentElement!.querySelector<SVGCircleElement>('.agent-map-agent-mark')!
expect(Number(marker.getAttribute('r'))).toBeGreaterThan(
Number(agentMark.getAttribute('r')) * 0.225
)
expect(marker).toHaveAttribute('vector-effect', 'none')
})
it('grows attention markers more gently than the inverse zoom while keeping a size floor', () => {
const mapScale = 0.2
const markerScale = agentMapAttentionMarkerScale(mapScale)
expect(markerScale).toBeGreaterThan(1)
expect(markerScale).toBeLessThan(1 / mapScale)
expect(AGENT_MAP_AGENT_RADIUS * 0.225 * markerScale * mapScale).toBeGreaterThanOrEqual(2.25)
})
it('shows worktree details and opens a running agent', () => {
const onOpenTerminal = vi.fn()
const running = card()
@@ -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(
<AgentMap
cards={[agent]}
now={NOW}
onOpenTerminal={vi.fn()}
selectedPaneKey={agent.paneKey}
/>
)
act(() => frames.shift()?.(0))
expect(screen.getByText('100%')).toBeInTheDocument()
act(() => frames.shift()?.(120))
expect(screen.getByText('249%')).toBeInTheDocument()
act(() => frames.shift()?.(240))
expect(screen.getByText('270%')).toBeInTheDocument()
})
it('keeps the selected node centered when topology changes around it', () => {
const selected = card()
const view = renderMap([selected], { selectedPaneKey: selected.paneKey })
@@ -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<string, RepoIcon | null>
now: number
className?: string
@@ -32,7 +34,7 @@ type AgentMapProps = {
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
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<AgentMapState> = new Set<AgentMapState>([
'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
@@ -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<string, TuiAgent[]>
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<AgentMapCanvasHandle, AgentMapCanvasProps>(
function AgentMapCanvas(
{
@@ -81,15 +104,18 @@ export const AgentMapCanvas = forwardRef<AgentMapCanvasHandle, AgentMapCanvasPro
worldPerPixelY: number
} | null>(null)
const viewportFrameRef = useRef<number | null>(null)
const pendingViewportRef = useRef<Viewport | null>(null)
const pendingViewportRef = useRef<AgentMapViewport | null>(null)
const interactionBoundsRef = useRef<DOMRect | null>(null)
const focusedAgentRef = useRef<{ paneKey: string; x: number; y: number } | null>(null)
const hasShownProjectsRef = useRef(layout.projects.length > 0)
const [size, setSize] = useState<ViewportSize>({ width: 800, height: 560 })
const [viewport, setViewport] = useState<Viewport>({
const clearInteractionBounds = useCallback(() => {
interactionBoundsRef.current = null
}, [])
const size = useAgentMapCanvasSize(containerRef, clearInteractionBounds)
const [viewport, setViewport] = useState<AgentMapViewport>({
center: { x: layout.width / 2, y: layout.height / 2 },
zoom: 1
})
const prefersReducedMotion = usePrefersReducedMotion()
const viewportRef = useRef(viewport)
const { contextMenus, onOpenProjectContextMenu, onOpenWorkspaceContextMenu } =
useAgentMapContextMenus({
@@ -114,29 +140,61 @@ export const AgentMapCanvas = forwardRef<AgentMapCanvasHandle, AgentMapCanvasPro
const mapScale = size.width / viewWidth
const labelScale = Math.max(1, 1 / mapScale)
const viewBox = `${center.x - viewWidth / 2} ${center.y - viewHeight / 2} ${viewWidth} ${viewHeight}`
const focusZoom = agentFocusZoom(layout, size.width, size.height)
const resolveFocusZoom = useCallback((): number => {
const bounds = containerRef.current?.getBoundingClientRect()
return bounds && bounds.width > 0 && bounds.height > 0
? agentFocusZoom(layout, bounds.width, bounds.height)
: focusZoom
}, [focusZoom, layout])
const 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<AgentMapCanvasHandle, AgentMapCanvasPro
}
}, [fit, hasProjects])
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) {
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<AgentMapCanvasHandle, AgentMapCanvasPro
[]
)
useEffect(() => {
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<SVGGElement>, 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 =
@@ -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')
})
})
@@ -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<string, TuiAgent[]>
nodeRefs: MutableRefObject<Map<string, SVGGElement>>
onSelectAgent: (card: DashboardCard, side: 'left' | 'right') => void
onSelectAgent: (card: DashboardCard) => void
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
onOpenProjectContextMenu?: (
event: React.MouseEvent<SVGCircleElement>,
@@ -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<string | null>(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<string, VisibleAgentLocation>()
for (const project of layout.projects) {
for (const worktree of project.worktrees) {
const selected = worktree.agents.some((agent) => agent.card.paneKey === selectedPaneKey)
if (!selected && shouldAggregateAgentMapWorktree(worktree, zoom, allowAggregation)) {
continue
}
for (const agent of worktree.agents) {
agents.set(agent.card.paneKey, { agent, worktreeId: worktree.id })
}
}
}
return agents
}, [allowAggregation, layout, selectedPaneKey, zoom])
return (
<>
{layout.projects.map((project) => {
const worktreesById = new Map(project.worktrees.map((worktree) => [worktree.id, worktree]))
const projectLabelHalfWidth = project.radius * mapScale
const 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 (
<g key={project.id}>
<circle
@@ -86,35 +143,6 @@ export const AgentMapScene = memo(function AgentMapScene({
: undefined
}
/>
<g
transform={`translate(${project.x} ${project.y - project.radius}) scale(${labelScale})`}
>
<foreignObject
className="agent-map-project-label-frame"
x={-project.radius}
y={3}
width={project.radius * 2}
height={18}
>
<div className="agent-map-project-label">
<RepoIconGlyph
repoIcon={repoIconsByRepoId?.[project.id] ?? null}
className="size-3 shrink-0"
iconClassName="size-3"
/>
<span className="shrink-0">{project.name.toUpperCase()}</span>
</div>
</foreignObject>
{visibleLabels.projectCountIds.has(project.id) ? (
<text className="agent-map-project-count" y={32}>
{translate(
'dashboardPopout.map.projectCount',
'{{agents}} agents · {{workspaces}} workspaces',
{ agents: project.agentCount, workspaces: project.worktrees.length }
).toUpperCase()}
</text>
) : null}
</g>
<g className="agent-map-worktree-lineage-links" aria-hidden>
{project.worktrees.map((child) => {
const parent = child.parentId ? worktreesById.get(child.parentId) : undefined
@@ -130,25 +158,79 @@ export const AgentMapScene = memo(function AgentMapScene({
)
})}
</g>
<g className="agent-map-lineage-links" aria-hidden>
{crossWorktreeLineage.map(({ parent, child }) => (
<path
key={child.card.paneKey}
className="agent-map-lineage-link is-cross-worktree"
data-agent-map-lineage-link=""
data-agent-map-cross-worktree-lineage-link=""
data-parent-pane-key={parent.card.paneKey}
data-child-pane-key={child.card.paneKey}
d={agentLineagePath(parent, child)}
/>
))}
</g>
{project.worktrees.map((worktree) => (
<AgentMapWorktreeRingNode
key={worktree.id}
project={project}
worktree={worktree}
zoom={zoom}
labelScale={labelScale}
mapScale={mapScale}
selectedPaneKey={selectedPaneKey}
allowAggregation={allowAggregation}
labelVisible={visibleLabels.worktreeIds.has(worktree.id)}
launchableAgents={launchableAgentsByWorktreeId?.[worktree.worktreeId]}
nodeRefs={nodeRefs}
onSelectAgent={onSelectAgent}
onSpawnAgent={onSpawnAgent}
onOpenWorkspaceContextMenu={onOpenWorkspaceContextMenu}
onLabelActiveChange={handleLabelActiveChange}
onAgentKeyDown={onAgentKeyDown}
/>
))}
<g className="agent-map-worktree-label-layer">
{project.worktrees.map((worktree) => (
<AgentMapWorktreeLabel
key={worktree.id}
worktree={worktree}
visible={visibleLabels.worktreeIds.has(worktree.id)}
active={
activeWorktreeId === worktree.id &&
visibleLabels.worktreeAgentSafeIds.has(worktree.id)
}
labelScale={labelScale}
mapScale={mapScale}
/>
))}
</g>
<g
transform={`translate(${project.x} ${project.y - project.radius}) scale(${labelScale})`}
>
<foreignObject
className="agent-map-project-label-frame"
x={-projectLabelHalfWidth}
y={3}
width={projectLabelHalfWidth * 2}
height={18}
>
<div className="agent-map-project-label">
<RepoIconGlyph
repoIcon={repoIconsByRepoId?.[project.id] ?? null}
className="size-3 shrink-0"
iconClassName="size-3"
/>
<span className="agent-map-project-name min-w-0 truncate">
{project.name.toUpperCase()}
</span>
</div>
</foreignObject>
{visibleLabels.projectCountIds.has(project.id) ? (
<text className="agent-map-project-count" y={32}>
{projectCountText}
</text>
) : null}
</g>
</g>
)
})}
@@ -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 (
<g
className={`agent-map-worktree-label-group${visible ? ' is-visible' : ''}${active ? ' is-active' : ''}${showCount ? ' is-count-visible' : ''}`}
transform={`translate(${worktree.x} ${worktree.y - worktree.radius}) scale(${labelScale})`}
>
<text className="agent-map-worktree-label" y={18}>
{worktree.name}
</text>
<text className="agent-map-worktree-count" y={32}>
{agentCountText}
</text>
</g>
)
})
@@ -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<Map<string, SVGGElement>>
onSelectAgent: (card: DashboardCard, side: 'left' | 'right') => void
onSelectAgent: (card: DashboardCard) => void
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
onOpenWorkspaceContextMenu?: (
event: React.MouseEvent<SVGCircleElement>,
worktree: AgentMapWorktreeRing
) => void
onLabelActiveChange: (worktreeId: string, active: boolean) => void
onAgentKeyDown: (event: React.KeyboardEvent<SVGGElement>, 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')}
</h3>
<div className="scrollbar-sleek max-h-56 space-y-0.5 overflow-y-auto">
{worktree.agents.map((agent) => (
<button
key={agent.card.paneKey}
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
onClick={(event) => {
onSelectAgent(agent.card, panelSideFor(event.currentTarget))
onDone()
}}
>
<AgentIcon agent={agentTypeToIconAgent(agent.card.agentType)} size={14} />
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] font-medium">
{agentName(agent.card)}
{worktree.agents.length === 0 ? (
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">
{translate('dashboardPopout.map.noWorkspaceAgents', 'No agents in this workspace.')}
</p>
) : (
worktree.agents.map((agent) => (
<button
key={agent.card.paneKey}
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
onClick={() => {
onSelectAgent(agent.card)
onDone()
}}
>
<AgentIcon agent={agentTypeToIconAgent(agent.card.agentType)} size={14} />
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] font-medium">
{agentName(agent.card)}
</span>
<span className="block truncate text-[11px] text-muted-foreground">
{agentStateLabel(agent.status)} · {formatDuration(agent.durationMinutes)}
</span>
</span>
<span className="block truncate text-[11px] text-muted-foreground">
{agentStateLabel(agent.status)} · {formatDuration(agent.durationMinutes)}
</span>
</span>
<AgentStateDot state={agent.status} size="md" />
</button>
))}
<AgentStateDot state={agent.status} size="md" />
</button>
))
)}
</div>
</section>
{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 (
<Popover open={detailsOpen} onOpenChange={setDetailsOpen}>
<g className="agent-map-worktree-group">
<g
className="agent-map-worktree-group"
onPointerEnter={() => 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)
}
}}
>
<PopoverTrigger asChild>
<circle
className={`agent-map-worktree-ring${selected ? ' is-selected' : ''}${detailsOpen ? ' is-open' : ''}`}
data-agent-map-worktree=""
data-agent-count={worktree.agents.length}
cx={worktree.x}
cy={worktree.y}
r={worktree.radius}
@@ -231,21 +244,6 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({
}
/>
</PopoverTrigger>
<g
className={`agent-map-worktree-label-group${labelVisible ? ' is-visible' : ''}${showCount ? ' is-count-visible' : ''}`}
transform={`translate(${worktree.x} ${worktree.y - worktree.radius}) scale(${labelScale})`}
>
<text className="agent-map-worktree-label" y={18}>
{worktree.name}
</text>
<text className="agent-map-worktree-count" y={32}>
{translate(
'dashboardPopout.map.agentCount',
worktree.agents.length === 1 ? '{{count}} agent' : '{{count}} agents',
{ count: worktree.agents.length }
)}
</text>
</g>
{aggregate ? (
<g
className="agent-map-aggregate-node"
@@ -289,10 +287,14 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({
data-agent-provider={agent.card.agentType}
role="button"
tabIndex={0}
aria-pressed={selectedPaneKey === agent.card.paneKey}
aria-label={`${agentName(agent.card)}, ${agentStateLabel(agent.status)}${agent.card.unseen ? ', unread' : ''}, ${formatDuration(agent.durationMinutes)}, ${worktree.name}, ${project.name}`}
className={`agent-map-agent-node fleet-status-${agent.status}${selectedPaneKey === agent.card.paneKey ? ' is-selected' : ''}`}
transform={`translate(${agent.x} ${agent.y})`}
onClick={(event) => onSelectAgent(agent.card, panelSideFor(event.currentTarget))}
onClick={(event) => {
event.currentTarget.focus()
onSelectAgent(agent.card)
}}
onKeyDown={(event) => onAgentKeyDown(event, agent)}
>
<circle className="agent-map-agent-hit" r={Math.max(10, agent.radius + 3)} />
@@ -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}
@@ -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<AgentDashboardView>(() =>
_props.view === 'map' || _props.view === 'rings' ? 'map' : 'board'
return <AgentKanbanBoard snapshot={snapshot} initialView={initialView} />
)
useEffect(() => window.api.dashboard.onViewRequested(setView), [])
return <AgentKanbanBoard key={view} snapshot={snapshot} initialView={view} />
}
@@ -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<T extends string>(values: T[], value: T): T[] {
return values.includes(value)
? values.filter((candidate) => candidate !== value)
@@ -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)
}
})
}
@@ -23,7 +23,7 @@ function worktree(overrides: Partial<AgentMapWorktreeRing> = {}): 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 }) }),
@@ -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<number, Map<number, LabelBox[]>>
export type AgentMapVisibleLabels = {
/** Worktree ring ids whose name can render without colliding. */
worktreeIds: Set<string>
/** Worktree names that hover/focus may reveal without covering an agent. */
worktreeAgentSafeIds: Set<string>
/** Project ids whose agent/workspace count line still has room. The count is
* the first thing dropped: it repeats what the filter rail already says. */
projectCountIds: Set<string>
@@ -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<string>()
const worktreeAgentSafeIds = new Set<string>()
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 }
}
@@ -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 }
}
@@ -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> = {}): DashboardCard {
}
}
function workspace(overrides: Partial<DashboardWorkspace> = {}): DashboardWorkspace {
return {
repoId: 'repo-1',
worktreeId: 'empty-worktree',
repoName: 'Orca',
worktreeName: 'Empty worktree',
hostKind: 'local',
executionHostId: 'local',
workspaceKind: 'worktree',
...overrides
}
}
describe('agent map layout', () => {
it('lays out agentless workspaces and preserves their workspace lineage', () => {
const layout = deriveAgentMapLayout([], NOW, [
workspace({ worktreeId: 'parent', worktreeName: 'Parent' }),
workspace({
worktreeId: 'child',
worktreeName: 'Child',
parentWorktreeId: 'parent'
})
])
const project = layout.projects[0]
const parent = project.worktrees.find((item) => item.worktreeId === 'parent')!
const child = project.worktrees.find((item) => item.worktreeId === 'child')!
expect(project.agentCount).toBe(0)
expect(parent.agents).toEqual([])
expect(child.parentId).toBe(parent.id)
expect(child.y).toBeGreaterThan(parent.y)
})
it('derives project containment, workspace containment, and every agent node', () => {
const cards = [
card({ paneKey: 'a', repoId: 'repo-a', worktreeId: 'wt-a' }),
@@ -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' }),
@@ -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<DashboardCardDotState, number>
@@ -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<AgentMapWorktreeRing, 'x' | 'y'> & { x: number; y: number }
type LocalProject = Omit<AgentMapProjectRing, 'x' | 'y' | 'worktrees'> & {
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<string, DashboardCard>,
now: number
): LocalProject {
const byWorktree = new Map<string, DashboardCard[]>()
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<string, DashboardCard[]>()
const byProject = new Map<string, { cards: DashboardCard[]; workspaces: DashboardWorkspace[] }>()
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,
@@ -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)
)
}
@@ -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
@@ -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<T extends ProjectCircle>(projects: T[]): T[] {
let cursorX = 0
return projects.map((project) => {
const positioned = { ...project, x: cursorX + project.radius, y: 0 }
cursorX += project.radius * 2 + PROJECT_GAP
return positioned
})
}
export function placeAgentMapProjects<T extends ProjectCircle>(
projects: T[],
minimumWidth: number,
minimumHeight: number,
worldMargin: number
): { projects: T[]; width: number; height: number } {
const positioned = projects.some((project) => project.clusterParentId)
? layoutAgentMapWorktreeLineage(projects)
: placeUnlinkedProjects(projects)
const left = Math.min(...positioned.map((project) => project.x - project.radius))
const right = Math.max(...positioned.map((project) => project.x + project.radius))
const top = Math.min(...positioned.map((project) => project.y - project.radius))
const bottom = Math.max(...positioned.map((project) => project.y + project.radius))
const naturalWidth = right - left + worldMargin * 2
const naturalHeight = bottom - top + worldMargin * 2
const width = Math.max(minimumWidth, naturalWidth)
const height = Math.max(minimumHeight, naturalHeight)
const offsetX = worldMargin - left + (width - naturalWidth) / 2
const offsetY = worldMargin - top + (height - naturalHeight) / 2
return {
projects: positioned.map((project) => ({
...project,
x: project.x + offsetX,
y: project.y + offsetY
})),
width,
height
}
}
@@ -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<string, DashboardCard>,
containerIdentity: (card: DashboardCard) => string
): string | undefined {
const ownContainerId = cards[0] ? containerIdentity(cards[0]) : undefined
const linkCounts = new Map<string, number>()
for (const card of cards) {
const parent = card.parentPaneKey ? cardsByPaneKey.get(card.parentPaneKey) : undefined
const parentContainerId = parent ? containerIdentity(parent) : undefined
if (!parentContainerId || parentContainerId === ownContainerId) {
continue
}
linkCounts.set(parentContainerId, (linkCounts.get(parentContainerId) ?? 0) + 1)
}
return [...linkCounts]
.sort(([leftId, leftCount], [rightId, rightCount]) =>
rightCount !== leftCount ? rightCount - leftCount : compareStable(leftId, rightId)
)
.at(0)?.[0]
}
@@ -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)
}
}
}
@@ -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)
}
@@ -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> = {}): DashboardCard {
return {
paneKey: 'pane-1',
ptyId: null,
agentType: 'codex',
bucket: 'working',
dotState: 'working',
task: '',
repoId: 'repo-1',
worktreeId: 'occupied',
tabId: 'tab-1',
leafId: null,
repoName: 'Orca',
worktreeName: 'Occupied',
executionHostId: 'local',
startedAt: 0,
finishedAt: null,
stateChangedAt: 0,
unseen: false,
...overrides
}
}
function workspace(overrides: Partial<DashboardWorkspace> = {}): DashboardWorkspace {
return {
repoId: 'repo-1',
worktreeId: 'empty',
repoName: 'Orca',
worktreeName: 'Empty child',
hostKind: 'local',
executionHostId: 'local',
workspaceKind: 'worktree',
workspaceStatusId: 'planned',
...overrides
}
}
describe('agent map workspace visibility', () => {
it('returns only workspaces that have no dashboard card on the same host', () => {
const result = selectAgentlessMapWorkspaces({
cards: [card()],
workspaces: [
workspace({ worktreeId: 'occupied', worktreeName: 'Occupied' }),
workspace(),
workspace({
worktreeId: 'occupied',
worktreeName: 'Remote twin',
hostKind: 'ssh',
executionHostId: 'ssh:build-box'
})
],
query: '',
filters: EMPTY_DASHBOARD_FILTERS
})
expect(result.map((item) => item.worktreeName)).toEqual(['Empty child', 'Remote twin'])
})
it('applies search and workspace filters to agentless workspaces', () => {
const result = selectAgentlessMapWorkspaces({
cards: [],
workspaces: [
workspace({ worktreeName: 'Listener security', review: { number: 42, state: 'open' } }),
workspace({ worktreeId: 'other', worktreeName: 'Unrelated', workspaceStatusId: 'active' })
],
query: 'listener',
filters: {
projects: ['repo-1'],
workspaceStatuses: ['planned'],
reviewStates: ['open']
}
})
expect(result.map((item) => item.worktreeName)).toEqual(['Listener security'])
})
})
@@ -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)
)
)
}
@@ -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)
})
})
@@ -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<T extends LineageWorktree>(
export function layoutAgentMapWorktreeLineage<T extends LineageWorktree>(worktrees: T[]): T[] {
const sorted = [...worktrees].sort((a, b) => compareStable(a.id, b.id))
const worktreesById = new Map(sorted.map((worktree) => [worktree.id, worktree]))
const clusterChildCounts = new Map<string, number>()
for (const worktree of sorted) {
if (worktree.clusterParentId && worktreesById.has(worktree.clusterParentId)) {
clusterChildCounts.set(
worktree.clusterParentId,
(clusterChildCounts.get(worktree.clusterParentId) ?? 0) + 1
)
}
}
const childrenByParent = new Map<string, T[]>()
const childIds = new Set<string>()
for (const worktree of sorted) {
const 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
}
@@ -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 {
@@ -0,0 +1,35 @@
import { useEffect, useState, type RefObject } from 'react'
export type AgentMapCanvasSize = { width: number; height: number }
export function useAgentMapCanvasSize(
containerRef: RefObject<HTMLDivElement | null>,
onResize: () => void
): AgentMapCanvasSize {
const [size, setSize] = useState<AgentMapCanvasSize>({ width: 800, height: 560 })
useEffect(() => {
const container = containerRef.current
if (!container || typeof ResizeObserver === 'undefined') {
return
}
const measure = (): void => {
const next = container.getBoundingClientRect()
if (next.width <= 0 || next.height <= 0) {
return
}
onResize()
setSize((current) =>
current.width === next.width && current.height === next.height
? current
: { width: next.width, height: next.height }
)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(container)
return () => observer.disconnect()
}, [containerRef, onResize])
return size
}
@@ -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
])
}
@@ -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 }
}
@@ -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(<AgentDashboardDrawer statusBarVisible />)
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', () => {
@@ -62,6 +62,11 @@ function AgentDashboardDrawerBody({
void window.api.dashboard.openPopout?.()
}, [onClose])
const handleOpenMap = useCallback(() => {
onClose()
void window.api.dashboard.openPopout?.('map')
}, [onClose])
return (
<AgentKanbanBoard
snapshot={snapshot}
@@ -73,8 +78,7 @@ function AgentDashboardDrawerBody({
onRevealAgent={handleRevealAgent}
onSpawnAgent={launchDashboardAgent}
onClose={onClose}
workspaceContextMenusEnabled
onWorkspaceContextMenuOpenChange={onMenuOpenChange}
onOpenMap={handleOpenMap}
headerActions={
<AgentDashboardSettingsMenu
onSwitchToPopout={handleSwitchToPopout}
@@ -0,0 +1,7 @@
import { useDashboardPopoutBridge } from './useDashboardPopoutBridge'
/** Opt-in pop-out IPC and snapshot subscriptions. */
export default function DashboardPopoutBridge(): null {
useDashboardPopoutBridge(true)
return null
}
@@ -0,0 +1,36 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const rendererRoot = join(__dirname, '../..')
function source(relativePath: string): string {
return readFileSync(join(rendererRoot, relativePath), 'utf8')
}
describe('agent dashboard performance isolation', () => {
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}')
})
})
@@ -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', () => {
@@ -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()
@@ -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<DashboardCardTerminalInputState & DashboardLaunchDetectionState>
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<string, RepoIcon | null> = {}
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<typeof selectRuntimeAgentOrchestrationForWorktree> | null =
null
let orchestrationByWorktree: ReturnType<typeof selectRuntimeAgentOrchestrationBatch> | 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
}
@@ -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'
}
}
@@ -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<Pick<AppState, 'workspaceStatuses'>>,
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 }
}
@@ -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<DashboardCard, 'hostKind' | 'executionHostId' | 'workspaceKind'> {
): {
hostKind: DashboardCardHostKind
executionHostId: ExecutionHostId
workspaceKind: DashboardCardWorkspaceKind
} {
return {
hostKind: dashboardCardHostKind(workspace, ptyId, terminalInput, clientPlatform),
executionHostId: getWorktreeExecutionHostId(workspace.worktree, workspace.repo ?? undefined),
@@ -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({
@@ -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<string, TuiAgent[]> {
const catalog = buildDashboardLaunchCatalog(state)
const cardsByWorktreeId = new Map<string, DashboardCard[]>()
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<string, TuiAgent[]> = {}
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<TuiAgent>(
detectedAgentsForWorktree(state, worktreeId, worktreeCards[0].repoId, catalog)
detectedAgentsForWorktree(state, worktreeId, repoId, catalog)
)
for (const card of worktreeCards) {
if (isTuiAgent(card.agentType)) {
@@ -138,7 +138,7 @@ describe('ExperimentalPane', () => {
<ExperimentalPane settings={settings} updateSettings={vi.fn()} />
)
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(
@@ -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<DashboardBucket, number>
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 (
<span className="flex items-center gap-1.5">
{active.map((bucket) => (
<span
key={bucket}
aria-label={`${dashboardBucketLabel(bucket)}: ${counts[bucket]}`}
className="inline-flex items-center gap-1 text-[10px] tabular-nums text-worktree-sidebar-foreground/55"
>
{bucket === 'attention' ? (
<MessageCircleQuestion className="size-2.5 text-amber-500" aria-hidden />
) : (
<span className={cn('size-1.5 rounded-full', DASHBOARD_BUCKET_DOT_CLASS[bucket])} />
)}
{counts[bucket]}
</span>
))}
</span>
)
}
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 (
<button
type="button"
onClick={() => {
if (openAsPopout) {
void window.api.dashboard.openPopout()
} else {
setAgentDashboardDrawerOpen(!drawerOpen)
}
}}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
'text-worktree-sidebar-foreground/60 hover:bg-worktree-sidebar-foreground/8'
)}
>
<LayoutDashboard
className="size-4 shrink-0 text-worktree-sidebar-foreground/30"
strokeWidth={1.75}
/>
<span className="flex-1">{translate('dashboard.sidebar.label', 'Agent Dashboard')}</span>
<DashboardBucketCounts counts={dashboardBucketCounts} showIdle={showIdle} />
</button>
)
}
@@ -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(
<AgentDashboardSidebarHost
sidebarOpen
workspaceBoardOpen
closeWorkspaceBoard={vi.fn()}
statusBarVisible
/>
)
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(
<AgentDashboardSidebarHost
sidebarOpen
workspaceBoardOpen={false}
closeWorkspaceBoard={closeWorkspaceBoard}
statusBarVisible
/>
)
act(() => useAppStore.setState({ agentDashboardDrawerOpen: true }))
view.rerender(
<AgentDashboardSidebarHost
sidebarOpen
workspaceBoardOpen={false}
closeWorkspaceBoard={closeWorkspaceBoard}
statusBarVisible
/>
)
await waitFor(() => expect(closeWorkspaceBoard).toHaveBeenCalledOnce())
})
it('clears an open dashboard when the sidebar closes', async () => {
useAppStore.setState({ agentDashboardDrawerOpen: true })
render(
<AgentDashboardSidebarHost
sidebarOpen={false}
workspaceBoardOpen={false}
closeWorkspaceBoard={vi.fn()}
statusBarVisible
/>
)
await waitFor(() => expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false))
})
})
@@ -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 ? (
<AgentDashboardDrawer leftSidebarStyle={leftSidebarStyle} statusBarVisible={statusBarVisible} />
) : null
}
@@ -219,63 +219,4 @@ describe('Sidebar', () => {
expect(fetchAllWorktrees).not.toHaveBeenCalled()
})
describe('companion board mutual exclusion', () => {
function renderWithDashboardOpen(): {
view: ReturnType<typeof render>
setAgentDashboardDrawerOpen: ReturnType<typeof vi.fn>
} {
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()
})
})
})
@@ -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"]')
@@ -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<GlobalSettings, 'experimentalAgentDashboardMode'> | null | undefined
): boolean {
return settings?.experimentalAgentDashboardMode === 'popout'
}
export function shouldShowMobileButton(
settings: Pick<GlobalSettings, 'showMobileButton'> | 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<DashboardBucket, number>
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 (
<span className="flex items-center gap-1.5">
{active.map((bucket) => (
<span
key={bucket}
aria-label={`${dashboardBucketLabel(bucket)}: ${counts[bucket]}`}
className="inline-flex items-center gap-1 text-[10px] tabular-nums text-worktree-sidebar-foreground/55"
>
{bucket === 'attention' ? (
<MessageCircleQuestion className="size-2.5 text-amber-500" aria-hidden />
) : (
<span className={cn('size-1.5 rounded-full', DASHBOARD_BUCKET_DOT_CLASS[bucket])} />
)}
{counts[bucket]}
</span>
))}
</span>
)
}
// 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 (
<button
type="button"
onClick={() => {
if (openAsPopout) {
void window.api.dashboard.openPopout()
} else {
// Why: like the workspace board trigger, the entry toggles its
// companion drawer — sidebar clicks do not auto-dismiss it.
setAgentDashboardDrawerOpen(!drawerOpen)
}
}}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
'text-worktree-sidebar-foreground/60 hover:bg-worktree-sidebar-foreground/8'
)}
>
<LayoutDashboard
className="size-4 shrink-0 text-worktree-sidebar-foreground/30"
strokeWidth={1.75}
/>
<span className="flex-1">{translate('dashboard.sidebar.label', 'Agent Dashboard')}</span>
<DashboardBucketCounts counts={dashboardBucketCounts} showIdle={showIdle} />
</button>
)
}
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() {
<HideSidebarMenu onHide={hideAutomationsButton} />
</ContextMenu>
) : null}
{showAgentDashboardButton ? <AgentDashboardSidebarEntry /> : null}
{showAgentDashboardButton ? (
<React.Suspense fallback={null}>
<AgentDashboardSidebarEntry />
</React.Suspense>
) : null}
{showAgentsButton ? (
<button
type="button"
+11 -28
View File
@@ -8,7 +8,6 @@ import SetupScriptPromptCard from './SetupScriptPromptCard'
import WorktreeList from './WorktreeList'
import SidebarToolbar from './SidebarToolbar'
import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer'
import { AgentDashboardDrawer } from '@/components/dashboard/AgentDashboardDrawer'
import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor'
import { cn } from '@/lib/utils'
import { FolderPlus, Loader2 } from 'lucide-react'
@@ -23,6 +22,7 @@ const RemoveFolderDialog = lazyWithRetry(() => import('./RemoveFolderDialog'))
const WorktreeVisibilityDialog = lazyWithRetry(() => import('./WorktreeVisibilityDialog'))
const OrcaYamlTrustDialog = lazyWithRetry(() => import('./OrcaYamlTrustDialog'))
const ForgetSshWorkspaceDialog = lazyWithRetry(() => import('./ForgetSshWorkspaceDialog'))
const AgentDashboardSidebarHost = lazyWithRetry(() => import('./AgentDashboardSidebarHost'))
const MIN_WIDTH = 220
const MAX_WIDTH = 500
@@ -92,28 +92,6 @@ function Sidebar({
}
}, [closeWorkspaceBoard, sidebarOpen, workspaceBoardRenderedOpen])
const agentDashboardDrawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen)
const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen)
useEffect(() => {
if (!sidebarOpen && agentDashboardDrawerOpen) {
setAgentDashboardDrawerOpen(false)
}
}, [agentDashboardDrawerOpen, setAgentDashboardDrawerOpen, sidebarOpen])
// Why: both companion boards expand into the same space beside the sidebar,
// so the most recently opened one dismisses the other.
useEffect(() => {
if (agentDashboardDrawerOpen) {
closeWorkspaceBoard()
}
}, [agentDashboardDrawerOpen, closeWorkspaceBoard])
// Why: a transient drag preview is not the user opening the board, so it must
// not evict the dashboard — key on the opened state, not the rendered state.
useEffect(() => {
if (workspaceBoardOpen) {
setAgentDashboardDrawerOpen(false)
}
}, [setAgentDashboardDrawerOpen, workspaceBoardOpen])
const { containerRef, onResizeStart, isResizing } = useSidebarResize<HTMLDivElement>({
isOpen: sidebarOpen,
width: sidebarWidth,
@@ -217,11 +195,16 @@ function Sidebar({
onMenuOpenChange={setWorkspaceBoardMenuOpen}
/>
) : null}
{sidebarOpen && settings?.experimentalAgentDashboardPopout === true ? (
<AgentDashboardDrawer
leftSidebarStyle={leftSidebarStyle}
statusBarVisible={statusBarVisible}
/>
{settings?.experimentalAgentDashboardPopout === true ? (
<React.Suspense fallback={null}>
<AgentDashboardSidebarHost
sidebarOpen={sidebarOpen}
workspaceBoardOpen={workspaceBoardOpen}
closeWorkspaceBoard={closeWorkspaceBoard}
leftSidebarStyle={leftSidebarStyle}
statusBarVisible={statusBarVisible}
/>
</React.Suspense>
) : null}
</TooltipProvider>
)
+2 -2
View File
@@ -108,8 +108,8 @@ describe('getDefaultSettings', () => {
})
it('keeps the agent dashboard popout disabled by default', () => {
expect(getDefaultSettings('/tmp').experimentalAgentDashboardPopout).toBe(false)
expect(getDefaultSettings('/tmp').experimentalAgentDashboardShowIdle).toBe(false)
expect(getDefaultSettings('/tmp').experimentalAgentDashboardPopout).toBeUndefined()
expect(getDefaultSettings('/tmp').experimentalAgentDashboardShowIdle).toBeUndefined()
})
it('routes fresh Codex profiles through the real-home rollout by default', () => {})
-4
View File
@@ -357,10 +357,6 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
// Why: off keeps the cosmetic overlay unmounted for users who never opt in.
experimentalPet: false,
experimentalActivity: false,
experimentalAgentDashboardPopout: false,
// Why: in-window screen popover is the default surface; users opt into a separate pop-out window.
experimentalAgentDashboardMode: 'in-window',
experimentalAgentDashboardShowIdle: false,
experimentalActivityDefaultedOffForAllUsers: true,
experimentalTerminalAttention: false,
experimentalAgentHibernation: false,
+24 -1
View File
@@ -30,6 +30,9 @@ export const DASHBOARD_MAX_LABEL_LENGTH = 1_024
* the launcher's entries rather than letting a huge fleet blank the pop-out. */
export const DASHBOARD_MAX_LAUNCH_WORKTREES = 500
/** Keeps optional map-only workspace metadata bounded across the renderer bridge. */
export const DASHBOARD_MAX_MAP_WORKSPACES = 2_000
/** Kept distinct from `bucket` so attention cards retain their precise dot state. */
export type DashboardCardDotState = 'working' | 'blocked' | 'waiting' | 'done' | 'idle'
@@ -54,6 +57,22 @@ export type DashboardCardSubagent = {
export type DashboardCardHostKind = 'local' | 'ssh' | 'wsl' | 'remote'
export type DashboardCardWorkspaceKind = 'worktree' | 'folder'
export type DashboardWorkspace = {
repoId: string
worktreeId: string
repoName: string
worktreeName: string
parentWorktreeId?: string
hostKind: DashboardCardHostKind
executionHostId: ExecutionHostId
workspaceKind: DashboardCardWorkspaceKind
workspaceStatusId?: string
workspaceStatusLabel?: string
workspaceStatusColor?: string
hasReview?: boolean
review?: DashboardCardReview
}
export type DashboardCard = {
/** Stable identity for React keys. */
paneKey: string
@@ -74,7 +93,7 @@ export type DashboardCard = {
worktreeId: string
tabId: string
leafId: string | null
/** Direct orchestration parent when both agents are visible in this workspace. */
/** Agent pane that spawned this agent, when both are visible. */
parentPaneKey?: string
/** Direct workspace parent. The map uses it only when both workspace rings are visible. */
parentWorktreeId?: string
@@ -150,6 +169,9 @@ export type DashboardFilterOptions = {
export type DashboardSnapshot = {
generatedAt: number
cards: DashboardCard[]
/** Active workspaces, including those without an agent card. Map-only and
* optional for preload compatibility with older snapshot producers. */
workspaces?: DashboardWorkspace[]
showIdle?: boolean
/** Available filter dimensions are store-derived so zero-card projects and
* statuses remain selectable. Optional for preload-version compatibility. */
@@ -166,6 +188,7 @@ export type DashboardSnapshot = {
export const EMPTY_DASHBOARD_SNAPSHOT: DashboardSnapshot = {
generatedAt: 0,
cards: [],
workspaces: [],
filterOptions: { projects: [], workspaceStatuses: [] },
launchableAgentsByWorktreeId: {},
repoIconsByRepoId: {}