mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
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:
@@ -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 }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user