diff --git a/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx b/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx index 5445a40ee47..132cf4ea544 100644 --- a/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx @@ -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} diff --git a/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx b/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx index 016b96bad94..b53f25adc7f 100644 --- a/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx @@ -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 onAgentStateToggle?: (state: AgentMapState) => void onAgentStatesReset?: () => void + /** Map-only: the board has no host dimension to slice. */ + enabledHosts?: ReadonlySet + 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 } -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 { - const counts = new Map() - 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() - 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() - 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({ ) : null} + {enabledHosts && hostCounts && onHostToggle ? ( + + ) : null} {translate('dashboardPopout.filters.project', 'Project')} diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx index 22cf8034c2d..2346db1da58 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx @@ -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( + + (['ssh'])} + onOpenTerminal={vi.fn()} + /> + + ) + + 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', () => { diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.tsx index 4d04af781a7..d1f500f295e 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.tsx @@ -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 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 + /** Owned by the board's filter menu, same reason. Defaults to every host. */ + enabledHosts?: ReadonlySet /** Owned by the board's filter menu. Defaults to shown. */ showOrchestrationLinks?: boolean launchableAgentsByWorktreeId?: Record @@ -42,39 +38,24 @@ type AgentMapProps = { onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void } -const HOST_FILTERS: AgentMapHostFilter[] = ['all', 'local', 'ssh', 'wsl', 'remote'] const ALL_AGENT_STATES: ReadonlySet = new Set([ 'attention', 'working', 'done', 'idle' ]) +const ALL_HOSTS: ReadonlySet = 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(null) const layoutCacheRef = useRef(null) - const [hostFilter, setHostFilter] = useState('all') - const hostCounts = useMemo(() => { - const counts: Record = { - 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({ } )} - {!compact ? ( -
- {HOST_FILTERS.filter((option) => option === 'all' || hostCounts[option] > 0).map( - (option) => ( - - ) - )} -
- ) : null} + 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 ( + <> + + {translate('dashboardPopout.map.filters.hosts', 'Hosts')} + + {hosts.map((host) => ( + onHostToggle(host)} + onSelect={(event) => event.preventDefault()} + > + {hostLabel(host)} + + + ))} + + + ) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-dashboard-filter-options.ts b/src/renderer/src/components/dashboard-popout/agent-dashboard-filter-options.ts new file mode 100644 index 00000000000..db9b42c57eb --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-dashboard-filter-options.ts @@ -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 { + const counts = new Map() + 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() + 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() + 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 { + 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') + } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts index e4a58040dc3..dc312118df4 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts @@ -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 { } } +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 }), diff --git a/src/renderer/src/components/dashboard-popout/agent-map-filter.ts b/src/renderer/src/components/dashboard-popout/agent-map-filter.ts index 900ca5cf91d..b1a9c71fb8f 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-filter.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-filter.ts @@ -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 +export type AgentMapHostCounts = Record + +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 - hostFilter: AgentMapHostFilter + enabledHosts: ReadonlySet }): 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, diff --git a/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx index 45c83a33c8e..a7c0b08f64e 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx +++ b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx @@ -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 { export type RenderMapOptions = { onOpenTerminal?: (card: DashboardCard) => void selectedPaneKey?: string | null - compact?: boolean workspaceContextMenusEnabled?: boolean enabledStates?: ReadonlySet + enabledHosts?: ReadonlySet showOrchestrationLinks?: boolean launchableAgentsByWorktreeId?: Record 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} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapHostFilter.ts b/src/renderer/src/components/dashboard-popout/useAgentMapHostFilter.ts new file mode 100644 index 00000000000..24040ce3315 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapHostFilter.ts @@ -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 + toggleHost: (host: DashboardCardHostKind) => void + resetHosts: () => void +} { + const [hosts, setHosts] = useState>( + () => 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 } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8c7329945ac..92d27cdf31d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 0dbd4d9b36f..a1282260488 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8c429f29d73..793e076e76b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -14470,14 +14470,12 @@ }, "map": { "host": { - "all": "すべてのホスト", "local": "ローカル", "ssh": "SSH", "wsl": "WSL", "remote": "リモート" }, "liveContainmentMap": "ライブ包含マップ", - "hostFilter": "ホストフィルター", "empty": "現在のフィルターに一致する Agent はありません。", "canvasLabel": "プロジェクト、ワークスペース、Agent の入れ子マップ", "zoomOut": "ズームアウト", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 09f846d3cdd..ebe5ba32504 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14481,14 +14481,12 @@ }, "map": { "host": { - "all": "모든 호스트", "local": "로컬", "ssh": "SSH", "wsl": "WSL", "remote": "원격" }, "liveContainmentMap": "실시간 포함 관계 맵", - "hostFilter": "호스트 필터", "empty": "현재 필터와 일치하는 에이전트가 없습니다.", "canvasLabel": "프로젝트, 작업 공간, 에이전트의 중첩 맵", "zoomOut": "축소", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b881b978574..fd20875ef82 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14490,14 +14490,12 @@ }, "map": { "host": { - "all": "所有主机", "local": "本地", "ssh": "SSH", "wsl": "WSL", "remote": "远程" }, "liveContainmentMap": "实时包含关系图", - "hostFilter": "主机筛选", "empty": "没有智能体符合当前筛选条件。", "canvasLabel": "项目、工作区和智能体的嵌套关系图", "zoomOut": "缩小",