From decbcba3207c73f98cdf160a0ef859098b58f9b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:33:04 -0700 Subject: [PATCH] feat(dashboard): filter the agent map by multiple hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../AgentDashboardMapView.tsx | 14 +- .../AgentDashboardToolbar.tsx | 157 +++++------------- .../dashboard-popout/AgentMap.test.tsx | 38 ++++- .../components/dashboard-popout/AgentMap.tsx | 84 ++-------- .../AgentMapHostFilterItems.tsx | 62 +++++++ .../agent-dashboard-filter-options.ts | 119 +++++++++++++ .../dashboard-popout/agent-map-filter.test.ts | 73 +++++++- .../dashboard-popout/agent-map-filter.ts | 37 ++++- .../agent-map-render-test-harness.tsx | 7 +- .../dashboard-popout/useAgentMapHostFilter.ts | 28 ++++ src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 2 - src/renderer/src/i18n/locales/ja.json | 2 - src/renderer/src/i18n/locales/ko.json | 2 - src/renderer/src/i18n/locales/zh.json | 2 - 15 files changed, 411 insertions(+), 219 deletions(-) create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapHostFilterItems.tsx create mode 100644 src/renderer/src/components/dashboard-popout/agent-dashboard-filter-options.ts create mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapHostFilter.ts 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": "缩小",