feat(dashboard): filter the agent map by multiple hosts

The map's host control was a single-choice segmented row in its own header:
pick All, Local, SSH, WSL, or Remote — never two at once. It also sat outside
the shared filter menu, so the menu's badge never counted it and "Clear all
filters" could not reach it.

Hosts are now checkboxes in the filter menu alongside agent state, so a fleet
split across a Mac and a Windows runtime can show both and hide the rest.
Hosts that contribute no agents are omitted, and the section hides entirely
for a single-host fleet.

Lifting `hostFilter` out of AgentMap also drops `compact`, which existed only
to hide the segmented control next to an open terminal panel.

The filter-option builders move to agent-dashboard-filter-options.ts to keep
AgentDashboardToolbar under the 400-line cap; that move is mechanical.
This commit is contained in:
Brennan Benson
2026-08-13 12:33:04 -07:00
parent 0824ed39ea
commit decbcba320
15 changed files with 411 additions and 219 deletions
@@ -14,7 +14,9 @@ import {
filterDashboardWorkspaces,
type DashboardFilters
} from './agent-board-filtering'
import { countAgentMapHosts } from './agent-map-filter'
import { selectAgentlessMapWorkspaces } from './agent-map-workspace-visibility'
import { useAgentMapHostFilter } from './useAgentMapHostFilter'
import { useAgentMapStateFilter } from './useAgentMapStateFilter'
const AgentMap = lazyWithRetry(
@@ -61,6 +63,7 @@ export function AgentDashboardMapView({
onWorkspaceContextMenuOpenChange
}: AgentDashboardMapViewProps): React.JSX.Element {
const { agentStates, toggleAgentState, resetAgentStates } = useAgentMapStateFilter()
const { hosts, toggleHost, resetHosts } = useAgentMapHostFilter()
const [showAgentlessWorkspaces, setShowAgentlessWorkspaces] = useState(false)
const [showOrchestrationLinks, setShowOrchestrationLinks] = useState(true)
const agentlessWorkspaces = useMemo(
@@ -73,6 +76,11 @@ export function AgentDashboardMapView({
}),
[snapshot.cards, snapshot.workspaces]
)
// Counted before host muting, so a muted host keeps its row and its count.
const hostCounts = useMemo(
() => countAgentMapHosts(snapshot.cards, agentlessWorkspaces),
[agentlessWorkspaces, snapshot.cards]
)
const visibleAgentlessWorkspaces = useMemo(
() =>
showAgentlessWorkspaces ? filterDashboardWorkspaces(agentlessWorkspaces, query, filters) : [],
@@ -92,6 +100,10 @@ export function AgentDashboardMapView({
agentStates={agentStates}
onAgentStateToggle={toggleAgentState}
onAgentStatesReset={resetAgentStates}
enabledHosts={hosts}
hostCounts={hostCounts}
onHostToggle={toggleHost}
onHostsReset={resetHosts}
showAgentlessWorkspaces={showAgentlessWorkspaces}
agentlessWorkspaceCount={agentlessWorkspaces.length}
onShowAgentlessWorkspacesChange={setShowAgentlessWorkspaces}
@@ -107,9 +119,9 @@ export function AgentDashboardMapView({
repoIconsByRepoId={snapshot.repoIconsByRepoId}
now={now}
className={dialogCard ? 'w-1/2 flex-none' : undefined}
compact={dialogCard !== null}
selectedPaneKey={dialogCard?.paneKey}
enabledStates={agentStates}
enabledHosts={hosts}
showOrchestrationLinks={showOrchestrationLinks}
launchableAgentsByWorktreeId={snapshot.launchableAgentsByWorktreeId}
workspaceContextMenusEnabled={workspaceContextMenusEnabled}
@@ -16,7 +16,7 @@ import { cn } from '@/lib/utils'
import { getWorkspaceStatusVisualMeta } from '../sidebar/workspace-status'
import type {
DashboardCard,
DashboardFilterOption,
DashboardCardHostKind,
DashboardFilterOptions
} from '../../../../shared/dashboard-snapshot'
import {
@@ -25,14 +25,27 @@ import {
type DashboardReviewFilter,
toggleDashboardFilter
} from './agent-board-filtering'
import { countAgentMapCards, type AgentMapState } from './agent-map-filter'
import {
ALL_AGENT_MAP_HOSTS,
countAgentMapCards,
type AgentMapHostCounts,
type AgentMapState
} from './agent-map-filter'
import {
AGENT_STATE_ROWS,
agentStateLabel,
projectOptions,
REVIEW_OPTIONS,
reviewCountsByState,
reviewStateLabel,
workspaceStatusOptions
} from './agent-dashboard-filter-options'
import { AgentDashboardFilterChips } from './AgentDashboardFilterChips'
import { AgentMapContentFilterItems } from './AgentMapContentFilterItems'
import { AgentMapHostFilterItems } from './AgentMapHostFilterItems'
import { FilterOptionCount } from './FilterOptionCount'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
type FilterOption = { id: string; label: string; count: number; color?: string }
type AgentDashboardToolbarProps = {
cards: DashboardCard[]
filterOptions?: DashboardFilterOptions
@@ -45,6 +58,11 @@ type AgentDashboardToolbarProps = {
agentStates?: ReadonlySet<AgentMapState>
onAgentStateToggle?: (state: AgentMapState) => void
onAgentStatesReset?: () => void
/** Map-only: the board has no host dimension to slice. */
enabledHosts?: ReadonlySet<DashboardCardHostKind>
hostCounts?: AgentMapHostCounts
onHostToggle?: (host: DashboardCardHostKind) => void
onHostsReset?: () => void
showAgentlessWorkspaces?: boolean
agentlessWorkspaceCount?: number
onShowAgentlessWorkspacesChange?: (show: boolean) => void
@@ -54,114 +72,6 @@ type AgentDashboardToolbarProps = {
searchInputRef: React.RefObject<HTMLInputElement | null>
}
const AGENT_STATE_ROWS: {
state: AgentMapState
dotState: 'waiting' | 'working' | 'done' | 'idle'
}[] = [
{ state: 'attention', dotState: 'waiting' },
{ state: 'working', dotState: 'working' },
{ state: 'done', dotState: 'done' },
{ state: 'idle', dotState: 'idle' }
]
function agentStateLabel(state: AgentMapState): string {
switch (state) {
case 'attention':
return translate('dashboardPopout.bucket.attention', 'Needs You')
case 'working':
return translate('dashboardPopout.bucket.working', 'Working')
case 'done':
return translate('dashboardPopout.bucket.done', 'Done')
case 'idle':
return translate('dashboardPopout.bucket.idle', 'Idle')
}
}
function countBy(
cards: DashboardCard[],
value: (card: DashboardCard) => string
): Map<string, number> {
const counts = new Map<string, number>()
for (const card of cards) {
const key = value(card)
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return counts
}
function workspaceStatusOptions(
cards: DashboardCard[],
configured: DashboardFilterOption[] | undefined
): FilterOption[] {
const counts = countBy(cards, (card) => card.workspaceStatusId ?? '')
if (configured) {
return configured.map((option) => ({
...option,
count: counts.get(option.id) ?? 0
}))
}
const options = new Map<string, FilterOption>()
for (const card of cards) {
if (!card.workspaceStatusId || options.has(card.workspaceStatusId)) {
continue
}
options.set(card.workspaceStatusId, {
id: card.workspaceStatusId,
label: card.workspaceStatusLabel ?? card.workspaceStatusId,
color: card.workspaceStatusColor,
count: counts.get(card.workspaceStatusId) ?? 0
})
}
return [...options.values()]
}
function projectOptions(
cards: DashboardCard[],
configured: DashboardFilterOption[] | undefined
): FilterOption[] {
const counts = countBy(cards, (card) => card.repoId)
if (configured) {
return configured.map((option) => ({
...option,
count: counts.get(option.id) ?? 0
}))
}
const options = new Map<string, FilterOption>()
for (const card of cards) {
if (!options.has(card.repoId)) {
options.set(card.repoId, {
id: card.repoId,
label: card.repoName,
count: counts.get(card.repoId) ?? 0
})
}
}
return [...options.values()]
}
const REVIEW_OPTIONS: readonly DashboardReviewFilter[] = [
'open',
'draft',
'merged',
'closed',
'none'
]
function reviewStateLabel(state: DashboardReviewFilter): string {
switch (state) {
case 'open':
return translate('dashboardPopout.filters.review.open', 'Open')
case 'draft':
return translate('dashboardPopout.filters.review.draft', 'Draft')
case 'merged':
return translate('dashboardPopout.filters.review.merged', 'Merged')
case 'closed':
return translate('dashboardPopout.filters.review.closed', 'Closed')
case 'none':
return translate('dashboardPopout.filters.review.none', 'No review')
}
}
export function AgentDashboardToolbar({
cards,
filterOptions,
@@ -173,6 +83,10 @@ export function AgentDashboardToolbar({
agentStates,
onAgentStateToggle,
onAgentStatesReset,
enabledHosts,
hostCounts,
onHostToggle,
onHostsReset,
showAgentlessWorkspaces,
agentlessWorkspaceCount = 0,
onShowAgentlessWorkspacesChange,
@@ -183,16 +97,19 @@ export function AgentDashboardToolbar({
const isMac = navigator.userAgent.includes('Mac')
const projects = projectOptions(cards, filterOptions?.projects)
const statuses = workspaceStatusOptions(cards, filterOptions?.workspaceStatuses)
const reviewCounts = countBy(
cards,
(card) => card.review?.state ?? (card.hasReview ? 'unknown' : 'none')
)
const reviewCounts = reviewCountsByState(cards)
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
// Hosts the fleet does not use are never offered, so they cannot read as muted.
const mutedHostCount =
enabledHosts && hostCounts
? ALL_AGENT_MAP_HOSTS.filter((host) => hostCounts[host] > 0 && !enabledHosts.has(host)).length
: 0
const activeCount =
activeDashboardFilterCount(filters) +
mutedStateCount +
mutedHostCount +
(showAgentlessWorkspaces === true ? 1 : 0) +
// Links show by default, so only hiding them is a deviation worth badging.
(showOrchestrationLinks === false ? 1 : 0)
@@ -211,6 +128,7 @@ export function AgentDashboardToolbar({
const clearFilters = (): void => {
onFiltersChange({ projects: [], workspaceStatuses: [], reviewStates: [] })
onAgentStatesReset?.()
onHostsReset?.()
onShowAgentlessWorkspacesChange?.(false)
onShowOrchestrationLinksChange?.(true)
}
@@ -311,6 +229,13 @@ export function AgentDashboardToolbar({
<DropdownMenuSeparator />
</>
) : null}
{enabledHosts && hostCounts && onHostToggle ? (
<AgentMapHostFilterItems
enabledHosts={enabledHosts}
hostCounts={hostCounts}
onHostToggle={onHostToggle}
/>
) : null}
<DropdownMenuLabel>
{translate('dashboardPopout.filters.project', 'Project')}
</DropdownMenuLabel>
@@ -1,11 +1,13 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, fireEvent, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AgentMap } from './AgentMap'
import { agentMapAttentionMarkerScale } from './agent-map-node-presentation'
import type { AgentMapState } from './agent-map-filter'
import type { DashboardCardHostKind } from '../../../../shared/dashboard-snapshot'
import { AGENT_MAP_AGENT_RADIUS } from './agent-map-layout'
import { card, installAgentMapEnvironment, NOW, renderMap } from './agent-map-render-test-harness'
@@ -411,7 +413,7 @@ describe('AgentMap', () => {
})
it('keeps a selected node visible while compacting around an adjacent terminal', () => {
const { container } = renderMap([card()], { selectedPaneKey: 'pane-1', compact: true })
const { container } = renderMap([card()], { selectedPaneKey: 'pane-1' })
const selectedNode = screen.getByRole('button', { name: /Agent alpha/ })
expect(selectedNode).toHaveClass('is-selected')
@@ -424,7 +426,37 @@ describe('AgentMap', () => {
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('narrows the map to the enabled hosts', () => {
// Rendered directly: a mixed-host map draws host badges, which need a
// tooltip provider the shared harness deliberately does not mount.
render(
<TooltipProvider>
<AgentMap
cards={[
card({ paneKey: 'pane-local' }),
card({
paneKey: 'pane-ssh',
worktreeId: 'worktree-2',
worktreeName: 'Remote map',
hostKind: 'ssh'
})
]}
now={NOW}
enabledHosts={new Set<DashboardCardHostKind>(['ssh'])}
onOpenTerminal={vi.fn()}
/>
</TooltipProvider>
)
expect(screen.getByText('1 of 2 agents shown')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Open Agent map worktree details' })
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Open Remote map worktree details' })
).toBeInTheDocument()
})
it('eases the viewport into the selected agent', () => {
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { useEffect, useMemo, useRef } from 'react'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import type {
@@ -12,11 +11,7 @@ import type {
import type { RepoIcon } from '../../../../shared/repo-icon'
import type { TuiAgent } from '../../../../shared/types'
import { AgentMapCanvas, type AgentMapCanvasHandle } from './AgentMapCanvas'
import {
filterAgentMapCards,
type AgentMapState,
type AgentMapHostFilter
} from './agent-map-filter'
import { ALL_AGENT_MAP_HOSTS, filterAgentMapCards, type AgentMapState } from './agent-map-filter'
import { updateAgentMapLayout, type AgentMapLayoutCache } from './agent-map-layout'
import { selectAgentMapRecentFinishPaneKeys } from './agent-map-node-metadata'
import './agent-map.css'
@@ -27,11 +22,12 @@ type AgentMapProps = {
repoIconsByRepoId?: Record<string, RepoIcon | null>
now: number
className?: string
compact?: boolean
selectedPaneKey?: string | null
/** Owned by the board so the shared toolbar filter can drive it. Defaults to
* every state, i.e. the map shows whatever it is handed. */
enabledStates?: ReadonlySet<AgentMapState>
/** Owned by the board's filter menu, same reason. Defaults to every host. */
enabledHosts?: ReadonlySet<DashboardCardHostKind>
/** Owned by the board's filter menu. Defaults to shown. */
showOrchestrationLinks?: boolean
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
@@ -42,39 +38,24 @@ type AgentMapProps = {
onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void
}
const HOST_FILTERS: AgentMapHostFilter[] = ['all', 'local', 'ssh', 'wsl', 'remote']
const ALL_AGENT_STATES: ReadonlySet<AgentMapState> = new Set<AgentMapState>([
'attention',
'working',
'done',
'idle'
])
const ALL_HOSTS: ReadonlySet<DashboardCardHostKind> = new Set(ALL_AGENT_MAP_HOSTS)
const EMPTY_WORKSPACES: DashboardWorkspace[] = []
function hostFilterLabel(filter: AgentMapHostFilter): string {
switch (filter) {
case 'all':
return translate('dashboardPopout.map.host.all', 'All hosts')
case 'local':
return translate('dashboardPopout.map.host.local', 'Local')
case 'ssh':
return translate('dashboardPopout.map.host.ssh', 'SSH')
case 'wsl':
return translate('dashboardPopout.map.host.wsl', 'WSL')
case 'remote':
return translate('dashboardPopout.map.host.remote', 'Remote')
}
}
export function AgentMap({
cards,
workspaces = EMPTY_WORKSPACES,
repoIconsByRepoId,
now,
className,
compact = false,
selectedPaneKey = null,
enabledStates = ALL_AGENT_STATES,
enabledHosts = ALL_HOSTS,
showOrchestrationLinks = true,
launchableAgentsByWorktreeId,
workspaceContextMenusEnabled = false,
@@ -85,37 +66,18 @@ export function AgentMap({
}: AgentMapProps): React.JSX.Element {
const canvasRef = useRef<AgentMapCanvasHandle>(null)
const layoutCacheRef = useRef<AgentMapLayoutCache | null>(null)
const [hostFilter, setHostFilter] = useState<AgentMapHostFilter>('all')
const hostCounts = useMemo(() => {
const counts: Record<DashboardCardHostKind, number> = {
local: 0,
ssh: 0,
wsl: 0,
remote: 0
}
for (const card of cards) {
counts[card.hostKind ?? 'local'] += 1
}
for (const workspace of workspaces) {
counts[workspace.hostKind] += 1
}
return counts
}, [cards, workspaces])
const visibleCards = useMemo(
() =>
filterAgentMapCards({
cards,
enabledStates,
hostFilter
enabledHosts
}),
[cards, enabledStates, hostFilter]
[cards, enabledStates, enabledHosts]
)
const visibleWorkspaces = useMemo(
() =>
hostFilter === 'all'
? workspaces
: workspaces.filter((workspace) => workspace.hostKind === hostFilter),
[hostFilter, workspaces]
() => workspaces.filter((workspace) => enabledHosts.has(workspace.hostKind)),
[enabledHosts, workspaces]
)
const layoutResult = useMemo(
() => updateAgentMapLayout(layoutCacheRef.current, visibleCards, now, visibleWorkspaces),
@@ -144,32 +106,6 @@ export function AgentMap({
}
)}
</strong>
{!compact ? (
<div
className="ml-auto flex items-center gap-0.5 rounded-md border border-border p-0.5"
role="group"
aria-label={translate('dashboardPopout.map.hostFilter', 'Host filter')}
>
{HOST_FILTERS.filter((option) => option === 'all' || hostCounts[option] > 0).map(
(option) => (
<Button
key={option}
type="button"
variant="ghost"
size="xs"
aria-pressed={hostFilter === option}
onClick={() => setHostFilter(option)}
className={cn(
'h-6 px-2 text-[10px]',
hostFilter === option && 'bg-accent text-accent-foreground'
)}
>
{hostFilterLabel(option)}
</Button>
)
)}
</div>
) : null}
</header>
<AgentMapCanvas
ref={canvasRef}
@@ -0,0 +1,62 @@
import {
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuSeparator
} from '@/components/ui/dropdown-menu'
import { translate } from '@/i18n/i18n'
import type { DashboardCardHostKind } from '../../../../shared/dashboard-snapshot'
import { ALL_AGENT_MAP_HOSTS, type AgentMapHostCounts } from './agent-map-filter'
import { FilterOptionCount } from './FilterOptionCount'
type AgentMapHostFilterItemsProps = {
enabledHosts: ReadonlySet<DashboardCardHostKind>
hostCounts: AgentMapHostCounts
onHostToggle: (host: DashboardCardHostKind) => void
}
function hostLabel(host: DashboardCardHostKind): string {
switch (host) {
case 'local':
return translate('dashboardPopout.map.host.local', 'Local')
case 'ssh':
return translate('dashboardPopout.map.host.ssh', 'SSH')
case 'wsl':
return translate('dashboardPopout.map.host.wsl', 'WSL')
case 'remote':
return translate('dashboardPopout.map.host.remote', 'Remote')
}
}
/**
* The map-only "Hosts" rows of the shared dashboard filter menu. Hosts that
* contribute nothing are omitted, so a single-host fleet never sees the section.
*/
export function AgentMapHostFilterItems({
enabledHosts,
hostCounts,
onHostToggle
}: AgentMapHostFilterItemsProps): React.JSX.Element | null {
const hosts = ALL_AGENT_MAP_HOSTS.filter((host) => hostCounts[host] > 0)
if (hosts.length < 2) {
return null
}
return (
<>
<DropdownMenuLabel>
{translate('dashboardPopout.map.filters.hosts', 'Hosts')}
</DropdownMenuLabel>
{hosts.map((host) => (
<DropdownMenuCheckboxItem
key={host}
checked={enabledHosts.has(host)}
onCheckedChange={() => onHostToggle(host)}
onSelect={(event) => event.preventDefault()}
>
<span className="truncate">{hostLabel(host)}</span>
<FilterOptionCount count={hostCounts[host]} />
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
</>
)
}
@@ -0,0 +1,119 @@
import { translate } from '@/i18n/i18n'
import type { DashboardCard, DashboardFilterOption } from '../../../../shared/dashboard-snapshot'
import type { DashboardReviewFilter } from './agent-board-filtering'
import type { AgentMapState } from './agent-map-filter'
/** Option rows and labels for the shared dashboard filter menu. */
export type FilterOption = { id: string; label: string; count: number; color?: string }
export const AGENT_STATE_ROWS: {
state: AgentMapState
dotState: 'waiting' | 'working' | 'done' | 'idle'
}[] = [
{ state: 'attention', dotState: 'waiting' },
{ state: 'working', dotState: 'working' },
{ state: 'done', dotState: 'done' },
{ state: 'idle', dotState: 'idle' }
]
export function agentStateLabel(state: AgentMapState): string {
switch (state) {
case 'attention':
return translate('dashboardPopout.bucket.attention', 'Needs You')
case 'working':
return translate('dashboardPopout.bucket.working', 'Working')
case 'done':
return translate('dashboardPopout.bucket.done', 'Done')
case 'idle':
return translate('dashboardPopout.bucket.idle', 'Idle')
}
}
function countBy(
cards: DashboardCard[],
value: (card: DashboardCard) => string
): Map<string, number> {
const counts = new Map<string, number>()
for (const card of cards) {
const key = value(card)
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return counts
}
export function workspaceStatusOptions(
cards: DashboardCard[],
configured: DashboardFilterOption[] | undefined
): FilterOption[] {
const counts = countBy(cards, (card) => card.workspaceStatusId ?? '')
if (configured) {
return configured.map((option) => ({
...option,
count: counts.get(option.id) ?? 0
}))
}
const options = new Map<string, FilterOption>()
for (const card of cards) {
if (!card.workspaceStatusId || options.has(card.workspaceStatusId)) {
continue
}
options.set(card.workspaceStatusId, {
id: card.workspaceStatusId,
label: card.workspaceStatusLabel ?? card.workspaceStatusId,
color: card.workspaceStatusColor,
count: counts.get(card.workspaceStatusId) ?? 0
})
}
return [...options.values()]
}
export function projectOptions(
cards: DashboardCard[],
configured: DashboardFilterOption[] | undefined
): FilterOption[] {
const counts = countBy(cards, (card) => card.repoId)
if (configured) {
return configured.map((option) => ({
...option,
count: counts.get(option.id) ?? 0
}))
}
const options = new Map<string, FilterOption>()
for (const card of cards) {
if (!options.has(card.repoId)) {
options.set(card.repoId, {
id: card.repoId,
label: card.repoName,
count: counts.get(card.repoId) ?? 0
})
}
}
return [...options.values()]
}
export function reviewCountsByState(cards: DashboardCard[]): Map<string, number> {
return countBy(cards, (card) => card.review?.state ?? (card.hasReview ? 'unknown' : 'none'))
}
export const REVIEW_OPTIONS: readonly DashboardReviewFilter[] = [
'open',
'draft',
'merged',
'closed',
'none'
]
export function reviewStateLabel(state: DashboardReviewFilter): string {
switch (state) {
case 'open':
return translate('dashboardPopout.filters.review.open', 'Open')
case 'draft':
return translate('dashboardPopout.filters.review.draft', 'Draft')
case 'merged':
return translate('dashboardPopout.filters.review.merged', 'Merged')
case 'closed':
return translate('dashboardPopout.filters.review.closed', 'Closed')
case 'none':
return translate('dashboardPopout.filters.review.none', 'No review')
}
}
@@ -1,6 +1,15 @@
import { describe, expect, it } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { agentMapState, countAgentMapCards, filterAgentMapCards } from './agent-map-filter'
import type {
DashboardCard,
DashboardCardHostKind,
DashboardWorkspace
} from '../../../../shared/dashboard-snapshot'
import {
agentMapState,
countAgentMapCards,
countAgentMapHosts,
filterAgentMapCards
} from './agent-map-filter'
const NOW = 2_000_000_000
@@ -27,6 +36,18 @@ function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
}
}
function workspace(worktreeId: string, hostKind: DashboardCardHostKind): DashboardWorkspace {
return {
repoId: 'repo-1',
worktreeId,
repoName: 'Orca',
worktreeName: worktreeId,
hostKind,
executionHostId: hostKind === 'local' ? 'local' : `ssh:${worktreeId}`,
workspaceKind: 'worktree'
}
}
describe('agent map filtering', () => {
it('files both unseen and acknowledged completions under done, never idle', () => {
expect(agentMapState(card({ unseen: true }))).toBe('done')
@@ -41,7 +62,7 @@ describe('agent map filtering', () => {
const visible = filterAgentMapCards({
cards: [hidden],
enabledStates: new Set(['done']),
hostFilter: 'ssh'
enabledHosts: new Set(['ssh'])
})
expect(visible).toEqual([hidden])
@@ -49,18 +70,60 @@ describe('agent map filtering', () => {
filterAgentMapCards({
cards: [hidden],
enabledStates: new Set(['idle']),
hostFilter: 'ssh'
enabledHosts: new Set(['ssh'])
})
).toEqual([])
expect(
filterAgentMapCards({
cards: [hidden],
enabledStates: new Set(['done']),
hostFilter: 'local'
enabledHosts: new Set(['local'])
})
).toEqual([])
})
it('keeps every selected host rather than one at a time', () => {
const local = card({ paneKey: 'local' })
const ssh = card({ paneKey: 'ssh', hostKind: 'ssh' })
const wsl = card({ paneKey: 'wsl', hostKind: 'wsl' })
expect(
filterAgentMapCards({
cards: [local, ssh, wsl],
enabledStates: new Set(['done']),
enabledHosts: new Set(['local', 'wsl'])
})
).toEqual([local, wsl])
})
it('treats a missing hostKind as local', () => {
const legacy = card({ paneKey: 'legacy', hostKind: undefined })
expect(
filterAgentMapCards({
cards: [legacy],
enabledStates: new Set(['done']),
enabledHosts: new Set(['local'])
})
).toEqual([legacy])
expect(
filterAgentMapCards({
cards: [legacy],
enabledStates: new Set(['done']),
enabledHosts: new Set(['ssh'])
})
).toEqual([])
})
it('counts cards and agentless workspaces into the same host tallies', () => {
expect(
countAgentMapHosts(
[card({ paneKey: 'a' }), card({ paneKey: 'b', hostKind: 'ssh' })],
[workspace('w-1', 'ssh'), workspace('w-2', 'wsl')]
)
).toEqual({ local: 1, ssh: 2, wsl: 1, remote: 0 })
})
it('counts all four display states', () => {
const cards = [
card({ paneKey: 'done-new', unseen: true }),
@@ -1,9 +1,20 @@
import type { DashboardCard, DashboardCardHostKind } from '../../../../shared/dashboard-snapshot'
import type {
DashboardCard,
DashboardCardHostKind,
DashboardWorkspace
} from '../../../../shared/dashboard-snapshot'
import { agentMapNodeStatus } from './agent-map-node-metadata'
export type AgentMapState = 'attention' | 'working' | 'done' | 'idle'
export type AgentMapHostFilter = 'all' | DashboardCardHostKind
export type AgentMapCounts = Record<AgentMapState, number>
export type AgentMapHostCounts = Record<DashboardCardHostKind, number>
export const ALL_AGENT_MAP_HOSTS: readonly DashboardCardHostKind[] = [
'local',
'ssh',
'wsl',
'remote'
]
export function agentMapState(card: DashboardCard): AgentMapState {
const state = agentMapNodeStatus(card)
@@ -21,21 +32,33 @@ export function agentMapState(card: DashboardCard): AgentMapState {
export function filterAgentMapCards({
cards,
enabledStates,
hostFilter
enabledHosts
}: {
cards: DashboardCard[]
enabledStates: ReadonlySet<AgentMapState>
hostFilter: AgentMapHostFilter
enabledHosts: ReadonlySet<DashboardCardHostKind>
}): DashboardCard[] {
// Project filtering lives in the shared toolbar filter, which has already
// narrowed these cards.
return cards.filter(
(card) =>
(hostFilter === 'all' || (card.hostKind ?? 'local') === hostFilter) &&
enabledStates.has(agentMapState(card))
(card) => enabledHosts.has(card.hostKind ?? 'local') && enabledStates.has(agentMapState(card))
)
}
export function countAgentMapHosts(
cards: DashboardCard[],
workspaces: readonly DashboardWorkspace[] = []
): AgentMapHostCounts {
const counts: AgentMapHostCounts = { local: 0, ssh: 0, wsl: 0, remote: 0 }
for (const card of cards) {
counts[card.hostKind ?? 'local'] += 1
}
for (const workspace of workspaces) {
counts[workspace.hostKind] += 1
}
return counts
}
export function countAgentMapCards(cards: DashboardCard[]): AgentMapCounts {
const counts: AgentMapCounts = {
attention: 0,
@@ -2,6 +2,7 @@ import { cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, vi } from 'vitest'
import type {
DashboardCard,
DashboardCardHostKind,
DashboardSleepWorkspaceArgs,
DashboardSpawnAgentArgs
} from '../../../../shared/dashboard-snapshot'
@@ -39,9 +40,9 @@ export function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
export type RenderMapOptions = {
onOpenTerminal?: (card: DashboardCard) => void
selectedPaneKey?: string | null
compact?: boolean
workspaceContextMenusEnabled?: boolean
enabledStates?: ReadonlySet<AgentMapState>
enabledHosts?: ReadonlySet<DashboardCardHostKind>
showOrchestrationLinks?: boolean
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
@@ -53,9 +54,9 @@ export function renderMap(
{
onOpenTerminal = vi.fn(),
selectedPaneKey = null,
compact = false,
workspaceContextMenusEnabled = false,
enabledStates,
enabledHosts,
showOrchestrationLinks,
launchableAgentsByWorktreeId,
onSpawnAgent,
@@ -68,9 +69,9 @@ export function renderMap(
now={NOW}
onOpenTerminal={onOpenTerminal}
selectedPaneKey={selectedPaneKey}
compact={compact}
workspaceContextMenusEnabled={workspaceContextMenusEnabled}
enabledStates={enabledStates}
enabledHosts={enabledHosts}
showOrchestrationLinks={showOrchestrationLinks}
launchableAgentsByWorktreeId={launchableAgentsByWorktreeId}
onSpawnAgent={onSpawnAgent}
@@ -0,0 +1,28 @@
import { useCallback, useState } from 'react'
import type { DashboardCardHostKind } from '../../../../shared/dashboard-snapshot'
import { ALL_AGENT_MAP_HOSTS } from './agent-map-filter'
/** Map-only host muting. It lives on the board rather than inside the map so the
* shared toolbar filter — and its clear-all — can drive it. */
export function useAgentMapHostFilter(): {
hosts: ReadonlySet<DashboardCardHostKind>
toggleHost: (host: DashboardCardHostKind) => void
resetHosts: () => void
} {
const [hosts, setHosts] = useState<ReadonlySet<DashboardCardHostKind>>(
() => new Set(ALL_AGENT_MAP_HOSTS)
)
const toggleHost = useCallback((host: DashboardCardHostKind): void => {
setHosts((current) => {
const next = new Set(current)
if (!next.delete(host)) {
next.add(host)
}
return next
})
}, [])
const resetHosts = useCallback((): void => {
setHosts(new Set(ALL_AGENT_MAP_HOSTS))
}, [])
return { hosts, toggleHost, resetHosts }
}
+1 -2
View File
@@ -15364,7 +15364,6 @@
},
"map": {
"host": {
"all": "All hosts",
"local": "Local",
"ssh": "SSH",
"wsl": "WSL",
@@ -15372,13 +15371,13 @@
},
"filters": {
"showStates": "Agent states",
"hosts": "Hosts",
"canvasSummary": "{{shown}} of {{total}} agents shown",
"agentlessWorkspaces": "Workspaces without agents",
"orchestrationLinks": "Orchestration links",
"workspaceVisibility": "Map content"
},
"liveContainmentMap": "Live containment map",
"hostFilter": "Host filter",
"empty": "No agents match the current filters.",
"canvasLabel": "Nested project, workspace, and agent map",
"zoomOut": "Zoom out",
-2
View File
@@ -14470,14 +14470,12 @@
},
"map": {
"host": {
"all": "Todos los hosts",
"local": "Local",
"ssh": "SSH",
"wsl": "WSL",
"remote": "Remoto"
},
"liveContainmentMap": "Mapa de contención en vivo",
"hostFilter": "Filtro de host",
"empty": "Ningún agente coincide con los filtros actuales.",
"canvasLabel": "Mapa anidado de proyectos, espacios de trabajo y agentes",
"zoomOut": "Alejar",
-2
View File
@@ -14470,14 +14470,12 @@
},
"map": {
"host": {
"all": "すべてのホスト",
"local": "ローカル",
"ssh": "SSH",
"wsl": "WSL",
"remote": "リモート"
},
"liveContainmentMap": "ライブ包含マップ",
"hostFilter": "ホストフィルター",
"empty": "現在のフィルターに一致する Agent はありません。",
"canvasLabel": "プロジェクト、ワークスペース、Agent の入れ子マップ",
"zoomOut": "ズームアウト",
-2
View File
@@ -14481,14 +14481,12 @@
},
"map": {
"host": {
"all": "모든 호스트",
"local": "로컬",
"ssh": "SSH",
"wsl": "WSL",
"remote": "원격"
},
"liveContainmentMap": "실시간 포함 관계 맵",
"hostFilter": "호스트 필터",
"empty": "현재 필터와 일치하는 에이전트가 없습니다.",
"canvasLabel": "프로젝트, 작업 공간, 에이전트의 중첩 맵",
"zoomOut": "축소",
-2
View File
@@ -14490,14 +14490,12 @@
},
"map": {
"host": {
"all": "所有主机",
"local": "本地",
"ssh": "SSH",
"wsl": "WSL",
"remote": "远程"
},
"liveContainmentMap": "实时包含关系图",
"hostFilter": "主机筛选",
"empty": "没有智能体符合当前筛选条件。",
"canvasLabel": "项目、工作区和智能体的嵌套关系图",
"zoomOut": "缩小",