refactor(agent-map): fold the map's filter rail into the shared toolbar filter

The rail duplicated the toolbar's project filter and cost the canvas 14rem of
width on the surface that needs it most. Agent states move into the toolbar's
Filter dropdown (map view only — the board's columns already separate them) and
count toward its badge; project filtering falls back to the toolbar's own. Show
all is the dropdown's Clear all, and Fit already lives in the viewport controls.
This commit is contained in:
Brennan Benson
2026-08-04 12:50:33 -07:00
parent 640bd81c39
commit 544281bf54
10 changed files with 198 additions and 283 deletions
@@ -1,4 +1,5 @@
import { ChevronDown, Filter, Search, X } from 'lucide-react'
import { AgentStateDot } from '@/components/AgentStateDot'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@@ -24,6 +25,7 @@ import {
type DashboardReviewFilter,
toggleDashboardFilter
} from './agent-board-filtering'
import { countAgentMapCards, type AgentMapState } from './agent-map-filter'
import { AgentDashboardFilterChips } from './AgentDashboardFilterChips'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
@@ -37,9 +39,36 @@ type AgentDashboardToolbarProps = {
onQueryChange: (query: string) => void
filters: DashboardFilters
onFiltersChange: (filters: DashboardFilters) => void
/** Map-only: the board's columns already separate agents by state. */
agentStates?: ReadonlySet<AgentMapState>
onAgentStateToggle?: (state: AgentMapState) => void
onAgentStatesReset?: () => void
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
@@ -137,6 +166,9 @@ export function AgentDashboardToolbar({
onQueryChange,
filters,
onFiltersChange,
agentStates,
onAgentStateToggle,
onAgentStatesReset,
searchInputRef
}: AgentDashboardToolbarProps): React.JSX.Element {
const isMac = navigator.userAgent.includes('Mac')
@@ -146,7 +178,10 @@ export function AgentDashboardToolbar({
cards,
(card) => card.review?.state ?? (card.hasReview ? 'unknown' : 'none')
)
const activeCount = activeDashboardFilterCount(filters)
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 toggleProject = (id: string): void =>
onFiltersChange({ ...filters, projects: toggleDashboardFilter(filters.projects, id) })
const toggleStatus = (id: string): void =>
@@ -159,8 +194,10 @@ export function AgentDashboardToolbar({
...filters,
reviewStates: toggleDashboardFilter(filters.reviewStates, id)
})
const clearFilters = (): void =>
const clearFilters = (): void => {
onFiltersChange({ projects: [], workspaceStatuses: [], reviewStates: [] })
onAgentStatesReset?.()
}
const reviewLabel = (id: DashboardReviewFilter): string =>
translate('dashboardPopout.filters.reviewChip', 'Review: {{state}}', {
state: reviewStateLabel(id)
@@ -227,6 +264,26 @@ export function AgentDashboardToolbar({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64" sideOffset={6}>
{agentStates && agentStateCounts ? (
<>
<DropdownMenuLabel>
{translate('dashboardPopout.map.filters.showStates', 'Agent states')}
</DropdownMenuLabel>
{AGENT_STATE_ROWS.map(({ state, dotState }) => (
<DropdownMenuCheckboxItem
key={state}
checked={agentStates.has(state)}
onCheckedChange={() => onAgentStateToggle?.(state)}
onSelect={(event) => event.preventDefault()}
>
<AgentStateDot state={dotState} size="md" />
<span className="truncate">{agentStateLabel(state)}</span>
<OptionCount count={agentStateCounts[state]} />
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuLabel>
{translate('dashboardPopout.filters.project', 'Project')}
</DropdownMenuLabel>
@@ -142,15 +142,54 @@ describe('AgentKanbanBoard', () => {
fireEvent.click(screen.getByRole('button', { name: 'Agent Map' }))
expect(await screen.findByText('0 of 0 agents shown')).toBeInTheDocument()
expect(screen.getByText('Agent states')).toBeInTheDocument()
expect(screen.queryByText('Live containment map')).not.toBeInTheDocument()
expect(screen.queryByText('Map filters')).not.toBeInTheDocument()
expect(screen.queryByText('Choose what stays visible')).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()
expect(screen.queryByText('Agent states')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Dashboard' }))
expect(screen.getByText('Needs You')).toBeInTheDocument()
})
it('filters the map from the shared toolbar filter, not a rail', async () => {
renderBoard([
card({ paneKey: 'busy', worktreeName: 'busy-wt', worktreeId: 'w-busy' }),
card({
paneKey: 'finished',
worktreeName: 'done-wt',
worktreeId: 'w-done',
bucket: 'done',
dotState: 'done',
finishedAt: 5,
unseen: true
})
])
fireEvent.click(screen.getByRole('button', { name: 'Agent Map' }))
expect(await screen.findByText('2 of 2 agents shown')).toBeInTheDocument()
fireEvent.pointerDown(screen.getByRole('button', { name: /^Filter/ }))
fireEvent.click(await screen.findByRole('menuitemcheckbox', { name: /Working/ }))
expect(await screen.findByText('1 of 2 agents shown')).toBeInTheDocument()
// A muted state counts toward the Filter badge like any other filter. The
// open menu hides the trigger from the a11y tree, so read it by its label.
expect(screen.getByRole('menu')).toHaveAccessibleName('Filter 1')
})
it('offers agent states only on the map, where no column separates them', async () => {
renderBoard([card({ paneKey: 'busy' })])
fireEvent.pointerDown(screen.getByRole('button', { name: /^Filter/ }))
expect(await screen.findByText('Project')).toBeInTheDocument()
expect(screen.queryByText('Agent states')).not.toBeInTheDocument()
fireEvent.keyDown(document.body, { key: 'Escape' })
fireEvent.click(screen.getByRole('button', { name: 'Agent Map' }))
fireEvent.pointerDown(screen.getByRole('button', { name: /^Filter/ }))
expect(await screen.findByText('Agent states')).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" />)
@@ -28,6 +28,7 @@ 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'
@@ -198,6 +199,7 @@ 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]
@@ -368,6 +370,9 @@ export function AgentKanbanBoard({
onQueryChange={setQuery}
filters={filters}
onFiltersChange={setFilters}
agentStates={view === 'map' ? agentStates : undefined}
onAgentStateToggle={toggleAgentState}
onAgentStatesReset={resetAgentStates}
searchInputRef={searchInputRef}
/>
{view !== 'board' ? (
@@ -389,6 +394,7 @@ export function AgentKanbanBoard({
}
compact={dialogCard !== null}
selectedPaneKey={dialogCard?.paneKey}
enabledStates={agentStates}
launchableAgentsByWorktreeId={snapshot.launchableAgentsByWorktreeId}
workspaceContextMenusEnabled={workspaceContextMenusEnabled}
onWorkspaceContextMenuOpenChange={onWorkspaceContextMenuOpenChange}
@@ -10,6 +10,7 @@ import type {
} from '../../../../shared/dashboard-snapshot'
import type { TuiAgent } from '../../../../shared/types'
import { AgentMap } from './AgentMap'
import type { AgentMapState } from './agent-map-filter'
import { AGENT_MAP_AGENT_RADIUS } from './agent-map-layout'
const NOW = 2_000_000_000
@@ -46,6 +47,7 @@ function renderMap(
selectedPaneKey = null,
compact = false,
workspaceContextMenusEnabled = false,
enabledStates,
launchableAgentsByWorktreeId,
onSpawnAgent,
onSleepWorkspace
@@ -54,6 +56,7 @@ function renderMap(
selectedPaneKey?: string | null
compact?: boolean
workspaceContextMenusEnabled?: boolean
enabledStates?: ReadonlySet<AgentMapState>
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void
@@ -67,6 +70,7 @@ function renderMap(
selectedPaneKey={selectedPaneKey}
compact={compact}
workspaceContextMenusEnabled={workspaceContextMenusEnabled}
enabledStates={enabledStates}
launchableAgentsByWorktreeId={launchableAgentsByWorktreeId}
onSpawnAgent={onSpawnAgent}
onSleepWorkspace={onSleepWorkspace}
@@ -158,7 +162,8 @@ describe('AgentMap', () => {
expect(ring).toHaveClass('is-open')
expect(screen.queryByRole('button', { name: 'Focus ring' })).not.toBeInTheDocument()
expect(screen.getAllByText('Orca')).toHaveLength(2)
// The popover names the project; the map itself draws it uppercased.
expect(screen.getByText('Orca')).toBeInTheDocument()
expect(screen.getByText('1 agent · 1 active · 0 done')).toBeInTheDocument()
expect(screen.getByText('Agent alpha')).toBeInTheDocument()
fireEvent.click(screen.getAllByRole('button', { name: /Agent alpha/ })[1])
@@ -325,12 +330,6 @@ describe('AgentMap', () => {
expect(viewportCenter()[1]).toBeCloseTo(nodeCenter()[1])
})
it('shows map filters at the pop-out default-width breakpoint', () => {
renderMap([card()])
expect(screen.getByRole('complementary', { name: 'Map filters' })).toHaveClass('md:flex')
})
it('increases map label scale when users zoom out', () => {
const { container } = renderMap([card()])
const labelGroup = container.querySelector('.agent-map-worktree-label')?.parentElement
@@ -441,42 +440,50 @@ describe('AgentMap', () => {
expect(screen.getByRole('button', { name: /Agent alpha/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /New result/ })).toHaveClass('fleet-status-done')
expect(screen.getByRole('button', { name: /Seen result/ })).toHaveClass('fleet-status-idle')
expect(screen.getByRole('button', { name: /^Done/ })).toHaveTextContent('1')
expect(screen.getByRole('button', { name: /^Idle/ })).toHaveTextContent('1')
})
it('lets users hide states and projects independently', () => {
renderMap([
card(),
card({
paneKey: 'other',
conversationName: 'Other project',
repoId: 'repo-2',
repoName: 'Mobile'
})
])
it('hides the states the toolbar filter has muted', () => {
const done = card({
paneKey: 'done',
conversationName: 'Done agent',
unseen: true,
bucket: 'done',
dotState: 'done',
finishedAt: NOW - 60_000
})
renderMap([card(), done], { enabledStates: new Set<AgentMapState>(['done']) })
fireEvent.click(screen.getByRole('button', { name: /^Working/ }))
expect(screen.queryByRole('button', { name: /Agent alpha/ })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /^Working/ }))
fireEvent.click(screen.getByRole('button', { name: /^Mobile/ }))
expect(screen.getByRole('button', { name: /Agent alpha/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Other project/ })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Done agent/ })).toBeInTheDocument()
})
it('preserves the viewport when filters temporarily empty the map', () => {
const { container } = renderMap([card()])
const agent = card()
const all = new Set<AgentMapState>(['attention', 'working', 'done', 'idle'])
const view = renderMap([agent], { enabledStates: all })
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
const viewBox = container
const viewBox = view.container
.querySelector<SVGSVGElement>('.agent-map-canvas > svg')!
.getAttribute('viewBox')
fireEvent.click(screen.getByRole('button', { name: /^Working/ }))
expect(container.querySelector('.agent-map-canvas > svg')).not.toBeInTheDocument()
expect(screen.getByText('1 agent hidden')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /^Working/ }))
const onOpenTerminal = vi.fn()
view.rerender(
<AgentMap
cards={[agent]}
now={NOW}
onOpenTerminal={onOpenTerminal}
enabledStates={new Set<AgentMapState>(['done'])}
/>
)
expect(view.container.querySelector('.agent-map-canvas > svg')).not.toBeInTheDocument()
view.rerender(
<AgentMap cards={[agent]} now={NOW} onOpenTerminal={onOpenTerminal} enabledStates={all} />
)
expect(container.querySelector('.agent-map-canvas > svg')).toHaveAttribute('viewBox', viewBox)
expect(view.container.querySelector('.agent-map-canvas > svg')).toHaveAttribute(
'viewBox',
viewBox
)
})
it('preserves the viewport through an empty source snapshot', () => {
@@ -11,9 +11,7 @@ import type {
import type { RepoIcon } from '../../../../shared/repo-icon'
import type { TuiAgent } from '../../../../shared/types'
import { AgentMapCanvas, type AgentMapCanvasHandle } from './AgentMapCanvas'
import { AgentMapFilterRail } from './AgentMapFilterRail'
import {
countAgentMapCards,
filterAgentMapCards,
type AgentMapState,
type AgentMapHostFilter
@@ -28,6 +26,9 @@ type AgentMapProps = {
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>
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
workspaceContextMenusEnabled?: boolean
onWorkspaceContextMenuOpenChange?: (open: boolean) => void
@@ -37,7 +38,12 @@ type AgentMapProps = {
}
const HOST_FILTERS: AgentMapHostFilter[] = ['all', 'local', 'ssh', 'wsl', 'remote']
const DEFAULT_STATES: AgentMapState[] = ['attention', 'working', 'done', 'idle']
const ALL_AGENT_STATES: ReadonlySet<AgentMapState> = new Set<AgentMapState>([
'attention',
'working',
'done',
'idle'
])
function hostFilterLabel(filter: AgentMapHostFilter): string {
switch (filter) {
@@ -61,6 +67,7 @@ export function AgentMap({
className,
compact = false,
selectedPaneKey = null,
enabledStates = ALL_AGENT_STATES,
launchableAgentsByWorktreeId,
workspaceContextMenusEnabled = false,
onWorkspaceContextMenuOpenChange,
@@ -71,10 +78,6 @@ export function AgentMap({
const canvasRef = useRef<AgentMapCanvasHandle>(null)
const layoutCacheRef = useRef<AgentMapLayoutCache | null>(null)
const [hostFilter, setHostFilter] = useState<AgentMapHostFilter>('all')
const [enabledStates, setEnabledStates] = useState<Set<AgentMapState>>(
() => new Set(DEFAULT_STATES)
)
const [hiddenProjectIds, setHiddenProjectIds] = useState<Set<string>>(() => new Set())
const hostCounts = useMemo(() => {
const counts: Record<DashboardCardHostKind, number> = {
local: 0,
@@ -87,28 +90,14 @@ export function AgentMap({
}
return counts
}, [cards])
const projects = useMemo(() => {
const byId = new Map<string, { id: string; name: string; count: number }>()
for (const card of cards) {
const current = byId.get(card.repoId)
if (current) {
current.count += 1
} else {
byId.set(card.repoId, { id: card.repoId, name: card.repoName, count: 1 })
}
}
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name))
}, [cards])
const counts = useMemo(() => countAgentMapCards(cards), [cards])
const visibleCards = useMemo(
() =>
filterAgentMapCards({
cards,
enabledStates,
hostFilter,
hiddenProjectIds
hostFilter
}),
[cards, enabledStates, hiddenProjectIds, hostFilter]
[cards, enabledStates, hostFilter]
)
const layoutResult = useMemo(
() => updateAgentMapLayout(layoutCacheRef.current, visibleCards, now),
@@ -119,50 +108,8 @@ export function AgentMap({
}, [layoutResult.cache])
const layout = layoutResult.layout
const toggleState = (state: AgentMapState): void => {
setEnabledStates((current) => {
const next = new Set(current)
if (next.has(state)) {
next.delete(state)
} else {
next.add(state)
}
return next
})
}
const toggleProject = (projectId: string): void => {
setHiddenProjectIds((current) => {
const next = new Set(current)
if (next.has(projectId)) {
next.delete(projectId)
} else {
next.add(projectId)
}
return next
})
}
const showAll = (): void => {
setEnabledStates(new Set(DEFAULT_STATES))
setHiddenProjectIds(new Set())
setHostFilter('all')
}
return (
<section className={cn('flex min-h-0 flex-1', className)}>
{!compact ? (
<AgentMapFilterRail
counts={counts}
enabledStates={enabledStates}
hiddenProjectIds={hiddenProjectIds}
projects={projects}
totalCount={cards.length}
visibleCount={visibleCards.length}
onFit={() => canvasRef.current?.fit()}
onProjectToggle={toggleProject}
onShowAll={showAll}
onStateToggle={toggleState}
/>
) : null}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex min-h-12 shrink-0 items-center gap-3 border-b border-border px-3 py-2">
<strong className="min-w-0 truncate text-xs">
@@ -1,149 +0,0 @@
import { Scan } from 'lucide-react'
import { AgentStateDot } from '@/components/AgentStateDot'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import type { AgentMapCounts, AgentMapState } from './agent-map-filter'
type AgentMapProjectOption = {
id: string
name: string
count: number
}
type AgentMapFilterRailProps = {
counts: AgentMapCounts
enabledStates: ReadonlySet<AgentMapState>
hiddenProjectIds: ReadonlySet<string>
projects: AgentMapProjectOption[]
totalCount: number
visibleCount: number
onFit: () => void
onProjectToggle: (projectId: string) => void
onShowAll: () => void
onStateToggle: (state: AgentMapState) => void
}
const 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 stateLabel(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')
}
}
export function AgentMapFilterRail({
counts,
enabledStates,
hiddenProjectIds,
projects,
totalCount,
visibleCount,
onFit,
onProjectToggle,
onShowAll,
onStateToggle
}: AgentMapFilterRailProps): React.JSX.Element {
return (
<aside
aria-label={translate('dashboardPopout.map.filters.title', 'Map filters')}
className="scrollbar-sleek hidden min-h-0 w-56 shrink-0 overflow-y-auto border-r border-border bg-card/35 p-3 md:flex md:flex-col"
>
<section className="border-b border-border pb-3">
<span className="px-1 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate('dashboardPopout.map.filters.showStates', 'Agent states')}
</span>
<div className="mt-1.5 space-y-0.5">
{STATE_ROWS.map(({ state, dotState }) => {
const active = enabledStates.has(state)
return (
<button
key={state}
type="button"
aria-pressed={active}
onClick={() => onStateToggle(state)}
className={cn(
'grid h-8 w-full grid-cols-[14px_minmax(0,1fr)_auto] items-center gap-2 rounded-md px-2 text-left text-xs text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
active && 'bg-accent text-accent-foreground'
)}
>
<span className="inline-flex" aria-hidden>
<AgentStateDot state={dotState} size="md" />
</span>
<span>{stateLabel(state)}</span>
<strong className="text-[11px] font-medium tabular-nums">{counts[state]}</strong>
</button>
)
})}
</div>
</section>
<section className="border-b border-border py-3">
<span className="px-1 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate('dashboardPopout.filters.project', 'Project')}
</span>
<div className="mt-1.5 space-y-0.5">
{projects.map((project) => {
const active = !hiddenProjectIds.has(project.id)
return (
<button
key={project.id}
type="button"
aria-pressed={active}
onClick={() => onProjectToggle(project.id)}
className={cn(
'grid min-h-8 w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-2 rounded-md px-2 text-left text-xs text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
active && 'bg-accent text-accent-foreground'
)}
>
<span className="truncate font-medium">{project.name}</span>
<strong className="text-[11px] font-medium tabular-nums">{project.count}</strong>
</button>
)
})}
</div>
</section>
<div className="border-b border-border px-1 py-3">
<strong className="block text-xs tabular-nums">
{translate('dashboardPopout.search.results', '{{shown}} of {{total}} shown', {
shown: visibleCount,
total: totalCount
})}
</strong>
<span className="text-[10px] text-muted-foreground">
{translate(
'dashboardPopout.map.filters.hidden',
totalCount - visibleCount === 1 ? '{{count}} agent hidden' : '{{count}} agents hidden',
{ count: totalCount - visibleCount }
)}
</span>
</div>
<div className="mt-auto grid grid-cols-2 gap-1 pt-3">
<Button type="button" variant="ghost" size="xs" onClick={onShowAll}>
{translate('dashboardPopout.map.filters.showAll', 'Show all')}
</Button>
<Button type="button" variant="outline" size="xs" onClick={onFit}>
<Scan className="size-3" />
{translate('dashboardPopout.map.fit', 'Fit')}
</Button>
</div>
</aside>
)
}
@@ -34,13 +34,12 @@ describe('agent map filtering', () => {
expect(agentMapState(card({ bucket: 'idle', dotState: 'idle' }))).toBe('idle')
})
it('applies state, host, and project filters independently', () => {
it('applies state and host filters independently', () => {
const hidden = card({ paneKey: 'hidden', repoId: 'hidden', hostKind: 'ssh', unseen: true })
const visible = filterAgentMapCards({
cards: [hidden],
enabledStates: new Set(['done']),
hostFilter: 'ssh',
hiddenProjectIds: new Set()
hostFilter: 'ssh'
})
expect(visible).toEqual([hidden])
@@ -48,24 +47,14 @@ describe('agent map filtering', () => {
filterAgentMapCards({
cards: [hidden],
enabledStates: new Set(['idle']),
hostFilter: 'ssh',
hiddenProjectIds: new Set()
hostFilter: 'ssh'
})
).toEqual([])
expect(
filterAgentMapCards({
cards: [hidden],
enabledStates: new Set(['done']),
hostFilter: 'local',
hiddenProjectIds: new Set()
})
).toEqual([])
expect(
filterAgentMapCards({
cards: [hidden],
enabledStates: new Set(['done']),
hostFilter: 'ssh',
hiddenProjectIds: new Set(['hidden'])
hostFilter: 'local'
})
).toEqual([])
})
@@ -19,23 +19,19 @@ export function agentMapState(card: DashboardCard): AgentMapState {
export function filterAgentMapCards({
cards,
enabledStates,
hostFilter,
hiddenProjectIds
hostFilter
}: {
cards: DashboardCard[]
enabledStates: ReadonlySet<AgentMapState>
hostFilter: AgentMapHostFilter
hiddenProjectIds: ReadonlySet<string>
}): DashboardCard[] {
return cards.filter((card) => {
if (
(hostFilter !== 'all' && (card.hostKind ?? 'local') !== hostFilter) ||
hiddenProjectIds.has(card.repoId)
) {
return false
}
return enabledStates.has(agentMapState(card))
})
// 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))
)
}
export function countAgentMapCards(cards: DashboardCard[]): AgentMapCounts {
@@ -0,0 +1,29 @@
import { useCallback, useState } from 'react'
import type { AgentMapState } from './agent-map-filter'
const ALL_AGENT_STATES: AgentMapState[] = ['attention', 'working', 'done', 'idle']
/** Map-only state muting. It lives on the board rather than inside the map so
* the shared toolbar filter — the map has no rail of its own — can drive it. */
export function useAgentMapStateFilter(): {
agentStates: ReadonlySet<AgentMapState>
toggleAgentState: (state: AgentMapState) => void
resetAgentStates: () => void
} {
const [agentStates, setAgentStates] = useState<ReadonlySet<AgentMapState>>(
() => new Set(ALL_AGENT_STATES)
)
const toggleAgentState = useCallback((state: AgentMapState): void => {
setAgentStates((current) => {
const next = new Set(current)
if (!next.delete(state)) {
next.add(state)
}
return next
})
}, [])
const resetAgentStates = useCallback((): void => {
setAgentStates(new Set(ALL_AGENT_STATES))
}, [])
return { agentStates, toggleAgentState, resetAgentStates }
}
-6
View File
@@ -14789,13 +14789,7 @@
"remote": "Remote"
},
"filters": {
"title": "Map filters",
"control": "Choose what stays visible",
"showStates": "Agent states",
"hidden": "{{count}} agents hidden",
"hidden_one": "{{count}} agent hidden",
"hidden_other": "{{count}} agents hidden",
"showAll": "Show all",
"canvasSummary": "{{shown}} of {{total}} agents shown"
},
"liveContainmentMap": "Live containment map",