From 13c193a00a3ecccc4d4a589cd95363bfa92a53ec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:51:43 -0700 Subject: [PATCH] feat(dashboard): add agent status search board (#11042) * feat(dashboard): add agent status search board * fix(dashboard): keep idle controls reachable * chore: drop merge-only formatting drift * fix(dashboard): compare sparse subagent snapshots safely * fix(dashboard): satisfy settings handler lint * fix(dashboard): address review feedback * fix(dashboard): complete search and localized status copy * fix(dashboard): pad active filter row * fix(dashboard): keep idle control in board settings * fix(dashboard): source filters from workspace state * fix(dashboard): clarify PR and MR status filter * fix(dashboard): preserve review and board parity --- .../ipc/dashboard-payload-validation.test.ts | 48 ++- src/main/ipc/dashboard-payload-validation.ts | 83 ++++- .../AgentDashboardFilterChips.tsx | 87 +++++ .../AgentDashboardToolbar.tsx | 296 ++++++++++++++++++ .../AgentKanbanBoard.test.tsx | 82 ++++- .../dashboard-popout/AgentKanbanBoard.tsx | 44 ++- .../dashboard-popout/AgentKanbanCard.test.tsx | 81 ++++- .../dashboard-popout/AgentKanbanCard.tsx | 266 ++++++++++++---- .../agent-board-filtering.test.ts | 96 ++++++ .../dashboard-popout/agent-board-filtering.ts | 63 ++++ .../AgentDashboardSettingsMenu.test.tsx | 71 +++++ .../dashboard/AgentDashboardSettingsMenu.tsx | 27 +- .../build-dashboard-snapshot.test.ts | 106 ++++++- .../dashboard/build-dashboard-snapshot.ts | 77 ++++- .../dashboard/dashboard-card-context.test.ts | 130 ++++++++ .../dashboard/dashboard-card-context.ts | 94 ++++++ .../dashboard/useAgentBucketCounts.ts | 7 +- .../useDashboardPopoutBridge.test.tsx | 6 +- .../dashboard/useDashboardPopoutBridge.ts | 5 +- .../dashboard/useLiveDashboardSnapshot.ts | 13 +- .../AgentDashboardExperimentalSetting.tsx | 21 +- .../settings/ExperimentalPane.test.tsx | 27 +- .../components/sidebar/SidebarNav.test.tsx | 12 +- .../src/components/sidebar/SidebarNav.tsx | 16 +- src/renderer/src/i18n/locales/en.json | 47 ++- src/renderer/src/i18n/locales/es.json | 47 ++- src/renderer/src/i18n/locales/ja.json | 47 ++- src/renderer/src/i18n/locales/ko.json | 47 ++- src/renderer/src/i18n/locales/zh.json | 47 ++- src/shared/constants.test.ts | 1 + src/shared/constants.ts | 1 + src/shared/dashboard-snapshot.ts | 52 ++- src/shared/types.ts | 2 + 33 files changed, 1894 insertions(+), 155 deletions(-) create mode 100644 src/renderer/src/components/dashboard-popout/AgentDashboardFilterChips.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx create mode 100644 src/renderer/src/components/dashboard-popout/agent-board-filtering.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-board-filtering.ts create mode 100644 src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.test.tsx create mode 100644 src/renderer/src/components/dashboard/dashboard-card-context.test.ts create mode 100644 src/renderer/src/components/dashboard/dashboard-card-context.ts diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index afdf4b3661c..edc20d28e5a 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -20,13 +20,24 @@ const SNAPSHOT = { leafId: 'leaf-1', repoName: 'Orca', worktreeName: 'Dashboard', + workspaceStatusId: 'in-review', + workspaceStatusLabel: 'In review', + workspaceStatusColor: 'emerald', + hasReview: true, + review: { number: 11012, state: 'open' }, + subagents: [{ id: 'child-1', name: 'Review loop', dotState: 'working' }], startedAt: 1_699_999_000_000, finishedAt: null, stateChangedAt: 1_699_999_500_000, unseen: true, askSummary: '{"question":"Proceed?"}' } - ] + ], + showIdle: false, + filterOptions: { + projects: [{ id: 'repo-1', label: 'Orca' }], + workspaceStatuses: [{ id: 'in-review', label: 'In review', color: 'emerald' }] + } } satisfies DashboardSnapshot describe('dashboard payload validation', () => { @@ -48,6 +59,18 @@ describe('dashboard payload validation', () => { cards: [{ ...SNAPSHOT.cards[0], lastAgentMessage: 'x'.repeat(8_001) }] }) ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], review: { number: 0, state: 'open' } }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], subagents: [{ id: '', name: 'bad', dotState: 'idle' }] }] + }) + ).toBe(false) }) it('accepts repo icons a pop-out can safely render, and rejects the rest', () => { @@ -85,6 +108,29 @@ describe('dashboard payload validation', () => { expect(isDashboardSnapshot({ ...SNAPSHOT, repoIconsByRepoId: [] })).toBe(false) }) + it('accepts bounded filter options independently of cards', () => { + expect(isDashboardSnapshot({ ...SNAPSHOT, cards: [] })).toBe(true) + expect(isDashboardSnapshot({ ...SNAPSHOT, filterOptions: undefined })).toBe(true) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + filterOptions: { + ...SNAPSHOT.filterOptions, + projects: [{ id: '', label: 'Invalid' }] + } + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + filterOptions: { + ...SNAPSHOT.filterOptions, + workspaceStatuses: [{ id: 'todo', label: 'x'.repeat(1_025) }] + } + }) + ).toBe(false) + }) + it('bounds the conversation name', () => { expect( isDashboardSnapshot({ diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index 69abed2c930..add3e182856 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -8,11 +8,14 @@ import { } from '../../shared/agent-status-types' const MAX_DASHBOARD_CARDS = 1_000 +const MAX_DASHBOARD_SUBAGENTS = 100 const MAX_DASHBOARD_REPO_ICONS = 500 +const MAX_DASHBOARD_FILTER_OPTIONS = 500 const MAX_ID_LENGTH = 4_096 const MAX_LABEL_LENGTH = 1_024 -const DASHBOARD_BUCKETS = new Set(['attention', 'working', 'idle']) +const DASHBOARD_BUCKETS = new Set(['attention', 'working', 'done', 'idle']) const DASHBOARD_DOT_STATES = new Set(['working', 'blocked', 'waiting', 'done', 'idle']) +const DASHBOARD_REVIEW_STATES = new Set(['open', 'closed', 'merged', 'draft']) function isBoundedString(value: unknown, maxLength: number, allowEmpty = false): value is string { return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0) @@ -53,10 +56,44 @@ export function isDashboardSnapshot(value: unknown): value is DashboardSnapshot Array.isArray(snapshot.cards) && snapshot.cards.length <= MAX_DASHBOARD_CARDS && snapshot.cards.every(isDashboardCard) && + (snapshot.showIdle === undefined || typeof snapshot.showIdle === 'boolean') && + isDashboardFilterOptions(snapshot.filterOptions) && isDashboardRepoIcons(snapshot.repoIconsByRepoId) ) } +function isDashboardFilterOptions(value: unknown): boolean { + if (value === undefined) { + return true + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const options = value as Record + return ( + isDashboardFilterOptionList(options.projects) && + isDashboardFilterOptionList(options.workspaceStatuses) + ) +} + +function isDashboardFilterOptionList(value: unknown): boolean { + return ( + Array.isArray(value) && + value.length <= MAX_DASHBOARD_FILTER_OPTIONS && + value.every((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return false + } + const option = entry as Record + return ( + isBoundedString(option.id, MAX_ID_LENGTH) && + isBoundedString(option.label, MAX_LABEL_LENGTH, true) && + isOptionalBoundedString(option.color, MAX_ID_LENGTH) + ) + }) + ) +} + /** Repo icons reach the pop-out's ``, so each one must survive the * same sanitizer the settings picker writes through. */ function isDashboardRepoIcons(value: unknown): boolean { @@ -77,6 +114,44 @@ function isDashboardRepoIcons(value: unknown): boolean { ) } +function isDashboardReview(value: unknown): boolean { + if (value === undefined) { + return true + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const review = value as Record + return ( + isFiniteNumber(review.number) && + review.number > 0 && + typeof review.state === 'string' && + DASHBOARD_REVIEW_STATES.has(review.state) + ) +} + +function isDashboardSubagents(value: unknown): boolean { + if (value === undefined) { + return true + } + return ( + Array.isArray(value) && + value.length <= MAX_DASHBOARD_SUBAGENTS && + value.every((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return false + } + const subagent = entry as Record + return ( + isBoundedString(subagent.id, MAX_ID_LENGTH) && + isBoundedString(subagent.name, MAX_LABEL_LENGTH, true) && + typeof subagent.dotState === 'string' && + DASHBOARD_DOT_STATES.has(subagent.dotState) + ) + }) + ) +} + function isDashboardCard(value: unknown): boolean { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false @@ -99,6 +174,12 @@ function isDashboardCard(value: unknown): boolean { (card.leafId === null || isBoundedString(card.leafId, MAX_ID_LENGTH)) && isBoundedString(card.repoName, MAX_LABEL_LENGTH, true) && isBoundedString(card.worktreeName, MAX_LABEL_LENGTH, true) && + isOptionalBoundedString(card.workspaceStatusId, MAX_ID_LENGTH) && + isOptionalBoundedString(card.workspaceStatusLabel, MAX_LABEL_LENGTH) && + isOptionalBoundedString(card.workspaceStatusColor, MAX_ID_LENGTH) && + (card.hasReview === undefined || typeof card.hasReview === 'boolean') && + isDashboardReview(card.review) && + isDashboardSubagents(card.subagents) && isFiniteNumber(card.startedAt) && (card.finishedAt === null || isFiniteNumber(card.finishedAt)) && isFiniteNumber(card.stateChangedAt) && diff --git a/src/renderer/src/components/dashboard-popout/AgentDashboardFilterChips.tsx b/src/renderer/src/components/dashboard-popout/AgentDashboardFilterChips.tsx new file mode 100644 index 00000000000..e148e132c96 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentDashboardFilterChips.tsx @@ -0,0 +1,87 @@ +import { X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import type { DashboardFilters, DashboardReviewFilter } from './agent-board-filtering' + +type FilterLabel = { id: string; label: string } + +function ActiveChip({ + label, + onRemove +}: { + label: string + onRemove: () => void +}): React.JSX.Element { + return ( + + {label} + + + ) +} + +export function AgentDashboardFilterChips({ + filters, + projects, + statuses, + reviewLabel, + onProjectToggle, + onStatusToggle, + onReviewToggle, + onClear +}: { + filters: DashboardFilters + projects: FilterLabel[] + statuses: FilterLabel[] + reviewLabel: (id: DashboardReviewFilter) => string + onProjectToggle: (id: string) => void + onStatusToggle: (id: string) => void + onReviewToggle: (id: DashboardReviewFilter) => void + onClear: () => void +}): React.JSX.Element { + return ( +
+ + {translate('dashboardPopout.filters.active', 'Filters')} + + {filters.projects.map((id) => ( + option.id === id)?.label ?? id} + onRemove={() => onProjectToggle(id)} + /> + ))} + {filters.workspaceStatuses.map((id) => ( + option.id === id)?.label ?? id} + onRemove={() => onStatusToggle(id)} + /> + ))} + {filters.reviewStates.map((id) => ( + onReviewToggle(id)} + /> + ))} + +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx b/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx new file mode 100644 index 00000000000..6bb0f4d6a61 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx @@ -0,0 +1,296 @@ +import { ChevronDown, Filter, Search, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Input } from '@/components/ui/input' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { getWorkspaceStatusVisualMeta } from '../sidebar/workspace-status' +import type { + DashboardCard, + DashboardFilterOption, + DashboardFilterOptions +} from '../../../../shared/dashboard-snapshot' +import { + activeDashboardFilterCount, + type DashboardFilters, + type DashboardReviewFilter, + toggleDashboardFilter +} from './agent-board-filtering' +import { AgentDashboardFilterChips } from './AgentDashboardFilterChips' + +type FilterOption = { id: string; label: string; count: number; color?: string } + +type AgentDashboardToolbarProps = { + cards: DashboardCard[] + filterOptions?: DashboardFilterOptions + filteredCount: number + query: string + onQueryChange: (query: string) => void + filters: DashboardFilters + onFiltersChange: (filters: DashboardFilters) => void +} + +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') + } +} + +function OptionCount({ count }: { count: number }): React.JSX.Element { + return {count} +} + +export function AgentDashboardToolbar({ + cards, + filterOptions, + filteredCount, + query, + onQueryChange, + filters, + onFiltersChange +}: AgentDashboardToolbarProps): React.JSX.Element { + 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 activeCount = activeDashboardFilterCount(filters) + const toggleProject = (id: string): void => + onFiltersChange({ ...filters, projects: toggleDashboardFilter(filters.projects, id) }) + const toggleStatus = (id: string): void => + onFiltersChange({ + ...filters, + workspaceStatuses: toggleDashboardFilter(filters.workspaceStatuses, id) + }) + const toggleReview = (id: DashboardReviewFilter): void => + onFiltersChange({ + ...filters, + reviewStates: toggleDashboardFilter(filters.reviewStates, id) + }) + const clearFilters = (): void => + onFiltersChange({ projects: [], workspaceStatuses: [], reviewStates: [] }) + const reviewLabel = (id: DashboardReviewFilter): string => + translate('dashboardPopout.filters.reviewChip', 'Review: {{state}}', { + state: reviewStateLabel(id) + }) + + return ( + <> +
+
+ + onQueryChange(event.target.value)} + placeholder={translate( + 'dashboardPopout.search.placeholder', + 'Search worktree, project, or agent…' + )} + aria-label={translate('dashboardPopout.search.label', 'Search agents')} + className="h-7 bg-muted/55 pr-7 pl-7 text-xs" + /> + {query ? ( + + ) : null} +
+ {query || activeCount > 0 ? ( + + {translate('dashboardPopout.search.results', '{{shown}} of {{total}} shown', { + shown: filteredCount, + total: cards.length + })} + + ) : null} + + + + + + + {translate('dashboardPopout.filters.project', 'Project')} + + {projects.map((option) => ( + toggleProject(option.id)} + onSelect={(event) => event.preventDefault()} + > + {option.label} + + + ))} + + + {translate('dashboardPopout.filters.workspaceStatus', 'Workspace status')} + + {statuses.map((option) => { + const meta = getWorkspaceStatusVisualMeta({ + id: option.id, + label: option.label, + color: option.color + }) + return ( + toggleStatus(option.id)} + onSelect={(event) => event.preventDefault()} + > + + {option.label} + + + ) + })} + + + {translate('dashboardPopout.filters.reviewStatus', 'PR / MR status')} + + {REVIEW_OPTIONS.map((option) => ( + toggleReview(option)} + onSelect={(event) => event.preventDefault()} + > + {reviewStateLabel(option)} + + + ))} + + + + {translate('dashboardPopout.filters.clearAll', 'Clear all filters')} + + + +
+ {activeCount > 0 ? ( + + ) : null} + + ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx index 941d3cfec03..f9390570e30 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx @@ -3,8 +3,13 @@ import '@testing-library/jest-dom/vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' -import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import type { + DashboardCard, + DashboardFilterOptions, + DashboardSnapshot +} from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' +import { i18n } from '@/i18n/i18n' import { AgentKanbanBoard } from './AgentKanbanBoard' // Stub the card and dialog so the board test stays free of xterm / Radix @@ -76,16 +81,21 @@ function card(overrides: Partial): DashboardCard { function renderBoard( cards: DashboardCard[], - repoIconsByRepoId?: Record + options: { + showIdle?: boolean + repoIconsByRepoId?: Record + filterOptions?: DashboardFilterOptions + } = {} ): void { - const snapshot: DashboardSnapshot = { generatedAt: 1, cards, repoIconsByRepoId } + const snapshot: DashboardSnapshot = { generatedAt: 1, cards, ...options } render() } const ackAgent = vi.fn(async () => {}) describe('AgentKanbanBoard', () => { - beforeEach(() => { + beforeEach(async () => { + await i18n.changeLanguage('en') // The board relays seen-acks through the dashboard preload API. ;(window as unknown as { api: unknown }).api = { dashboard: { ackAgent } } }) @@ -96,22 +106,22 @@ describe('AgentKanbanBoard', () => { vi.restoreAllMocks() }) - it('renders the three fixed columns in order', () => { + it('renders the three default columns in order', () => { renderBoard([]) - const headers = screen.getAllByText(/Needs You|Working|Idle/) - expect(headers.map((h) => h.textContent)).toEqual(['Needs You', 'Working', 'Idle']) + const headers = screen.getAllByText(/Needs You|Working|Done/) + expect(headers.map((h) => h.textContent)).toEqual(['Needs You', 'Working', 'Done']) }) it('places cards in their bucket column and counts them', () => { renderBoard([ card({ bucket: 'attention', worktreeName: 'a1' }), card({ bucket: 'attention', worktreeName: 'a2' }), - card({ bucket: 'idle', worktreeName: 'i1' }) + card({ bucket: 'done', worktreeName: 'd1' }) ]) const cards = screen.getAllByTestId('card') expect(cards).toHaveLength(3) expect(cards.filter((c) => c.dataset.bucket === 'attention')).toHaveLength(2) - expect(within(document.body).getByText('i1').dataset.bucket).toBe('idle') + expect(within(document.body).getByText('d1').dataset.bucket).toBe('done') expect(screen.getByText('3 total')).toBeTruthy() }) @@ -130,7 +140,7 @@ describe('AgentKanbanBoard', () => { card({ repoId: 'r2', worktreeName: 'from-r2' }), card({ repoId: 'r3', worktreeName: 'from-r3' }) ], - { r1: { type: 'lucide', name: 'Rocket' }, r2: null } + { repoIconsByRepoId: { r1: { type: 'lucide', name: 'Rocket' }, r2: null } } ) expect(screen.getByText('from-r1').dataset.repoIcon).toBe('{"type":"lucide","name":"Rocket"}') @@ -141,10 +151,54 @@ describe('AgentKanbanBoard', () => { it('shows "None" for empty columns', () => { renderBoard([card({ bucket: 'working' })]) - // attention and idle are empty → two "None" placeholders. + // attention and done are empty → two "None" placeholders. expect(screen.getAllByText('None')).toHaveLength(2) }) + it('shows the idle column only when enabled', () => { + renderBoard([card({ bucket: 'idle', worktreeName: 'quiet-agent' })], { showIdle: true }) + + expect(screen.getByText('Idle')).toBeInTheDocument() + expect(screen.getByText('quiet-agent')).toBeInTheDocument() + }) + + it('searches agent content and reports the visible result count', () => { + renderBoard([ + card({ worktreeName: 'first', task: 'repair relay authentication' }), + card({ worktreeName: 'second', task: 'update dashboard layout' }) + ]) + + fireEvent.change(screen.getByLabelText('Search agents'), { target: { value: 'relay' } }) + + expect(screen.getByText('first')).toBeInTheDocument() + expect(screen.queryByText('second')).not.toBeInTheDocument() + expect(screen.getByText('1 of 2 shown')).toBeInTheDocument() + }) + + it('localizes the new board status and filter controls', async () => { + await i18n.changeLanguage('ja') + renderBoard([card({ bucket: 'done' })]) + + expect(screen.getByText('完了')).toBeInTheDocument() + expect(screen.getByLabelText('エージェントを検索')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^フィルター/ })).toBeInTheDocument() + }) + + it('offers store-derived project and status filters without cards', async () => { + renderBoard([], { + filterOptions: { + projects: [{ id: 'r1', label: 'Repo One' }], + workspaceStatuses: [{ id: 'planned', label: 'Planned', color: 'neutral' }] + } + }) + + fireEvent.pointerDown(screen.getByRole('button', { name: /^Filter/ })) + + expect(await screen.findByText('Repo One')).toBeInTheDocument() + expect(screen.getByText('Planned')).toBeInTheDocument() + expect(screen.getByText('PR / MR status')).toBeInTheDocument() + }) + it('orders cards in a column by most recent bucket entry first', () => { renderBoard([ card({ bucket: 'working', worktreeName: 'old-move', stateChangedAt: 1000 }), @@ -197,14 +251,14 @@ describe('AgentKanbanBoard', () => { }) it('keeps the terminal dialog open across bucket moves and card removal', () => { - const agent = card({ paneKey: 'pk-1', bucket: 'idle', worktreeName: 'wt1' }) + const agent = card({ paneKey: 'pk-1', bucket: 'done', worktreeName: 'wt1' }) const { rerender } = render() expect(screen.getByTestId('terminal-dialog').dataset.open).toBe('false') fireEvent.click(screen.getByTestId('card')) expect(screen.getByTestId('terminal-dialog').dataset.open).toBe('true') - // Sending a message flips the agent idle → working; the dialog must + // Sending a message flips the agent done → working; the dialog must // follow the card to its new bucket instead of closing. const moved = { ...agent, bucket: 'working' as const, dotState: 'working' as const } rerender() @@ -219,7 +273,7 @@ describe('AgentKanbanBoard', () => { }) it('relays a seen-ack when a dialog opens and when the open agent changes state', () => { - const agent = card({ paneKey: 'pk-ack', bucket: 'idle', unseen: true }) + const agent = card({ paneKey: 'pk-ack', bucket: 'done', unseen: true }) const { rerender } = render() // unseen comes straight from the snapshot (the shared ack map). expect(screen.getByTestId('card').dataset.unseen).toBe('true') diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx index b43fc971efb..ded2be0c221 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx @@ -11,7 +11,13 @@ import { cn } from '@/lib/utils' import { TooltipProvider } from '@/components/ui/tooltip' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { AgentKanbanCard } from './AgentKanbanCard' +import { AgentDashboardToolbar } from './AgentDashboardToolbar' import { AgentTerminalDialog, type AgentRevealArgs } from './AgentTerminalDialog' +import { + EMPTY_DASHBOARD_FILTERS, + filterDashboardCards, + type DashboardFilters +} from './agent-board-filtering' import './agent-board-transitions.css' import { translate } from '@/i18n/i18n' @@ -35,6 +41,8 @@ function bucketLabel(bucket: DashboardBucket): string { 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') } @@ -44,6 +52,7 @@ function groupByBucket(cards: DashboardCard[]): Record = { attention: [], working: [], + done: [], idle: [] } for (const card of cards) { @@ -133,7 +142,22 @@ export function AgentKanbanBoard({ onClose, headerActions }: AgentKanbanBoardProps): React.JSX.Element { - const grouped = useMemo(() => groupByBucket(snapshot.cards), [snapshot.cards]) + const visibleBuckets = useMemo( + () => + DASHBOARD_BUCKET_ORDER.filter((bucket) => bucket !== 'idle' || snapshot.showIdle === true), + [snapshot.showIdle] + ) + const visibleCards = useMemo( + () => snapshot.cards.filter((card) => visibleBuckets.includes(card.bucket)), + [snapshot.cards, visibleBuckets] + ) + const [query, setQuery] = useState('') + const [filters, setFilters] = useState(EMPTY_DASHBOARD_FILTERS) + const filteredCards = useMemo( + () => filterDashboardCards(visibleCards, query, filters), + [filters, query, visibleCards] + ) + const grouped = useMemo(() => groupByBucket(filteredCards), [filteredCards]) const hasRelativeTimestamps = useMemo( () => snapshot.cards.some((card) => (card.finishedAt ?? card.startedAt) > 0), [snapshot.cards] @@ -204,7 +228,7 @@ export function AgentKanbanBoard({ {translate('dashboardPopout.total', '{{count}} total', { - count: snapshot.cards.length + count: visibleCards.length })} {headerActions || onClose ? ( @@ -223,13 +247,19 @@ export function AgentKanbanBoard({ ) : null} +
- {/* Why: columns share the window width up to a readable cap; mx-auto - centers the capped board so leftover space splits evenly instead of - pooling on the right. In overflow the auto margins collapse to 0, - keeping the left edge reachable while scrolling. */} + {/* Auto margins center the capped board and collapse during horizontal overflow. */}
- {DASHBOARD_BUCKET_ORDER.map((bucket) => ( + {visibleBuckets.map((bucket) => ( { expect(container.querySelector('.lucide-message-circle-question-mark')).toBeNull() }) + it('shows review metadata and expands grouped subagents without opening the terminal', () => { + const onOpenTerminal = vi.fn() + renderCard({ + card: card({ + review: { number: 11012, state: 'open' }, + subagents: [ + { id: 'child-1', name: 'Review loop', dotState: 'working' }, + { id: 'child-2', name: 'Smoke tests', dotState: 'done' } + ] + }), + now: 2_000, + onOpenTerminal + }) + + expect(screen.getByText('#11012')).toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Open review #11012' })).toBeInTheDocument() + expect(screen.queryByText('Review loop')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '2 subagents' })) + expect(screen.getByText('Review loop')).toBeInTheDocument() + expect(screen.getByText('Smoke tests')).toBeInTheDocument() + expect(onOpenTerminal).not.toHaveBeenCalled() + }) + + it('opens the terminal from the footer while keeping subagent disclosure isolated', () => { + const onOpenTerminal = vi.fn() + renderCard({ + card: card({ + conversationName: 'Dashboard review', + review: { number: 11042, state: 'open' }, + subagents: [{ id: 'child-1', name: 'Review loop', dotState: 'working' }] + }), + now: 61_000, + onOpenTerminal + }) + + fireEvent.click(screen.getByText('#11042')) + expect(onOpenTerminal).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: '1 subagent' })) + expect(onOpenTerminal).toHaveBeenCalledTimes(1) + }) + + it('labels one subagent and the workspace status accessibly', () => { + renderCard({ + card: card({ + workspaceStatusId: 'in-review', + workspaceStatusLabel: 'In review', + workspaceStatusColor: 'emerald', + subagents: [{ id: 'child-1', name: 'Review loop', dotState: 'working' }] + }), + now: 2_000 + }) + + expect(screen.getByRole('button', { name: '1 subagent' })).toBeInTheDocument() + expect(screen.getByRole('img', { name: 'In review' })).toBeInTheDocument() + }) + it('tints attention amber and done green, leaving every other state neutral', () => { const { container: attention } = renderCard({ card: card({ bucket: 'attention', dotState: 'waiting' }), now: 2_000 }) - expect(attention.querySelector('button')?.className).toContain('border-amber-500/40') + expect(attention.firstElementChild?.className).toContain('border-amber-500/40') cleanup() const { container: done } = renderCard({ card: card({ bucket: 'idle', dotState: 'done' }), now: 2_000 }) - expect(done.querySelector('button')?.className).toContain('border-emerald-500/40') + expect(done.firstElementChild?.className).toContain('border-emerald-500/40') cleanup() const { container: idle } = renderCard({ card: card({ bucket: 'idle', dotState: 'idle' }), now: 2_000 }) - const idleClassName = idle.querySelector('button')?.className ?? '' + const idleClassName = idle.firstElementChild?.className ?? '' expect(idleClassName).toContain('border-border/60') expect(idleClassName).not.toContain('emerald') expect(idleClassName).not.toContain('amber') @@ -133,10 +190,9 @@ describe('AgentKanbanCard', () => { now: 2_000 }) - const [header, footer] = [ - container.querySelector('button')!.firstElementChild!, - container.querySelector('button')!.lastElementChild! - ] + const cardElement = container.firstElementChild! + const header = cardElement.querySelector('button')!.firstElementChild! + const footer = cardElement.lastElementChild! expect(header).toHaveTextContent('Sparse-checkout parser') expect(header).not.toHaveTextContent('dashboard-review') expect(footer).toHaveTextContent('dashboard-review') @@ -166,7 +222,10 @@ describe('AgentKanbanCard', () => { it('skips structured-clone rerenders until visible card data or its age changes', () => { const onOpenTerminal = vi.fn() - const initial = card({ startedAt: 1_000 }) + const initial = card({ + startedAt: 1_000, + subagents: [{ id: 'child-1', name: 'Review loop', dotState: 'working' }] + }) const repoIcon: RepoIcon = { type: 'lucide', name: 'Rocket' } const { rerender } = render( @@ -185,7 +244,7 @@ describe('AgentKanbanCard', () => { rerender( ({ ...subagent })) }} repoIcon={{ ...repoIcon }} now={62_000} onOpenTerminal={onOpenTerminal} @@ -197,7 +256,7 @@ describe('AgentKanbanCard', () => { rerender( ({ ...subagent })) }} repoIcon={{ ...repoIcon }} now={121_500} onOpenTerminal={onOpenTerminal} diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx index 9262077e17d..e4b18fff93a 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -1,6 +1,13 @@ -import { memo } from 'react' +import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { MessageCircleQuestion } from 'lucide-react' +import { + ChevronRight, + GitMerge, + GitPullRequest, + GitPullRequestClosed, + GitPullRequestDraft, + MessageCircleQuestion +} from 'lucide-react' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import { AgentStateDot } from '@/components/AgentStateDot' @@ -10,6 +17,7 @@ import { cn } from '@/lib/utils' import type { DashboardCard } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' import { translate } from '@/i18n/i18n' +import { getWorkspaceStatusVisualMeta } from '../sidebar/workspace-status' /** Compact "started N ago" (the card is glanceable — coarse units are fine). */ function formatStartedAgo(startedAt: number, now: number): string { @@ -36,6 +44,39 @@ function displayTimestamp(card: DashboardCard): number { return card.finishedAt ?? card.startedAt } +function formatSubagentCount(count: number): string { + return count === 1 + ? translate('dashboardPopout.card.subagents_one', '{{count}} subagent', { count }) + : translate('dashboardPopout.card.subagents_other', '{{count}} subagents', { count }) +} + +function sameSubagents(a: DashboardCard['subagents'], b: DashboardCard['subagents']): boolean { + if (a === b) { + return true + } + if (!a || !b || a.length !== b.length) { + return false + } + for (let index = 0; index < a.length; index += 1) { + if (!(index in a) || !(index in b)) { + if (index in a !== index in b) { + return false + } + continue + } + const subagent = a[index] + const other = b[index] + if ( + subagent.id !== other.id || + subagent.name !== other.name || + subagent.dotState !== other.dotState + ) { + return false + } + } + return true +} + function sameCard(a: DashboardCard, b: DashboardCard): boolean { return ( a.paneKey === b.paneKey && @@ -52,6 +93,13 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean { a.leafId === b.leafId && a.repoName === b.repoName && a.worktreeName === b.worktreeName && + a.workspaceStatusId === b.workspaceStatusId && + a.workspaceStatusLabel === b.workspaceStatusLabel && + a.workspaceStatusColor === b.workspaceStatusColor && + a.hasReview === b.hasReview && + a.review?.number === b.review?.number && + a.review?.state === b.review?.state && + sameSubagents(a.subagents, b.subagents) && a.startedAt === b.startedAt && a.finishedAt === b.finishedAt && a.stateChangedAt === b.stateChangedAt && @@ -61,6 +109,57 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean { ) } +const REVIEW_PRESENTATION = { + open: { + icon: GitPullRequest, + className: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' + }, + draft: { + icon: GitPullRequestDraft, + className: 'border-border bg-muted/60 text-muted-foreground' + }, + merged: { + icon: GitMerge, + className: 'border-violet-500/30 bg-violet-500/10 text-violet-700 dark:text-violet-300' + }, + closed: { + icon: GitPullRequestClosed, + className: 'border-destructive/30 bg-destructive/10 text-destructive' + } +} as const + +function ReviewPill({ card }: { card: DashboardCard }): React.JSX.Element | null { + if (!card.review) { + return null + } + const presentation = REVIEW_PRESENTATION[card.review.state] + const Icon = presentation.icon + const title = (() => { + switch (card.review.state) { + case 'open': + return translate('dashboardPopout.card.review.open', 'Open review') + case 'draft': + return translate('dashboardPopout.card.review.draft', 'Draft review') + case 'merged': + return translate('dashboardPopout.card.review.merged', 'Merged review') + case 'closed': + return translate('dashboardPopout.card.review.closed', 'Closed review') + } + })() + return ( + + #{card.review.number} + + ) +} + /** Structural — the icon arrives inside a fresh structured clone each publish, * so identity alone would re-render every card several times a second. */ function sameRepoIcon(a: RepoIcon | null | undefined, b: RepoIcon | null | undefined): boolean { @@ -100,6 +199,15 @@ export const AgentKanbanCard = memo( onOpenTerminal }: AgentKanbanCardProps): React.JSX.Element { useTranslation() + const [subagentsOpen, setSubagentsOpen] = useState(false) + const workspaceStatusMeta = + card.workspaceStatusId && card.workspaceStatusLabel + ? getWorkspaceStatusVisualMeta({ + id: card.workspaceStatusId, + label: card.workspaceStatusLabel, + color: card.workspaceStatusColor + }) + : null // Why: the two outcomes worth scanning for get a tinted card — amber for // "answer me", green for "finished, look at it". Everything else stays // neutral so the tint keeps meaning something. @@ -112,16 +220,13 @@ export const AgentKanbanCard = memo( const worktreeInFooter = card.conversationName !== undefined return ( - + + {card.subagents?.length ? ( + <> + + {subagentsOpen ? ( +
+ {card.subagents.map((subagent) => ( +
+ + {subagent.name} +
+ ))}
) : null} - {card.lastAgentMessage ? ( -
- - {formatAgentTypeLabel(card.agentType)} - {' '} - {card.lastAgentMessage} -
- ) : null} -
- ) : card.task ? ( -
{card.task}
+ ) : null} - {/* Why: the card behind it is amber now, so the pill needs its own edge - to stay a distinct chip instead of a flat block of tint. */} - {card.askSummary ? ( -
- - {card.askSummary} -
- ) : null} - -
- {/* Why: the project reads as an icon so its name can't crowd the - worktree sitting next to it; the name lives in the tooltip. */} +
- + +
) }, (previous, next) => diff --git a/src/renderer/src/components/dashboard-popout/agent-board-filtering.test.ts b/src/renderer/src/components/dashboard-popout/agent-board-filtering.test.ts new file mode 100644 index 00000000000..e197c96c740 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-board-filtering.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { + EMPTY_DASHBOARD_FILTERS, + filterDashboardCards, + toggleDashboardFilter +} from './agent-board-filtering' + +function card(overrides: Partial): DashboardCard { + return { + paneKey: 'pane', + ptyId: null, + agentType: 'claude', + bucket: 'working', + dotState: 'working', + task: '', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: null, + repoName: 'Orca', + worktreeName: 'dashboard', + startedAt: 0, + finishedAt: null, + stateChangedAt: 0, + unseen: false, + ...overrides + } +} + +describe('agent board filtering', () => { + it('searches visible names, messages, review numbers, and subagent names', () => { + const cards = [ + card({ paneKey: 'conversation', conversationName: 'Sparse-checkout parser' }), + card({ paneKey: 'messages', lastAgentMessage: 'Relay authentication repaired' }), + card({ paneKey: 'review', review: { number: 11012, state: 'open' } }), + card({ + paneKey: 'subagent', + subagents: [{ id: 'child', name: 'terminal orphan cleanup', dotState: 'working' }] + }) + ] + + expect(filterDashboardCards(cards, 'sparse-checkout', EMPTY_DASHBOARD_FILTERS)[0].paneKey).toBe( + 'conversation' + ) + expect(filterDashboardCards(cards, 'authentication', EMPTY_DASHBOARD_FILTERS)[0].paneKey).toBe( + 'messages' + ) + expect(filterDashboardCards(cards, '#11012', EMPTY_DASHBOARD_FILTERS)[0].paneKey).toBe('review') + expect(filterDashboardCards(cards, 'orphan cleanup', EMPTY_DASHBOARD_FILTERS)[0].paneKey).toBe( + 'subagent' + ) + }) + + it('combines filter categories while treating values within a category as alternatives', () => { + const cards = [ + card({ + paneKey: 'match', + repoId: 'orca', + workspaceStatusId: 'in-review', + review: { number: 1, state: 'open' } + }), + card({ + paneKey: 'wrong-status', + repoId: 'orca', + workspaceStatusId: 'todo', + review: { number: 2, state: 'open' } + }) + ] + + const result = filterDashboardCards(cards, '', { + projects: ['orca', 'relay'], + workspaceStatuses: ['in-review'], + reviewStates: ['open'] + }) + + expect(result.map((candidate) => candidate.paneKey)).toEqual(['match']) + }) + + it('supports the synthesized no-review filter and immutable toggles', () => { + const cards = [ + card({ paneKey: 'none' }), + card({ paneKey: 'open', review: { number: 1, state: 'open' } }), + card({ paneKey: 'linked-uncached', hasReview: true }) + ] + + expect( + filterDashboardCards(cards, '', { + ...EMPTY_DASHBOARD_FILTERS, + reviewStates: ['none'] + }).map((candidate) => candidate.paneKey) + ).toEqual(['none']) + expect(toggleDashboardFilter(['open'], 'draft')).toEqual(['open', 'draft']) + expect(toggleDashboardFilter(['open', 'draft'], 'open')).toEqual(['draft']) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts b/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts new file mode 100644 index 00000000000..d7a4c132150 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts @@ -0,0 +1,63 @@ +import type { DashboardCard, DashboardCardReview } from '../../../../shared/dashboard-snapshot' + +export type DashboardReviewFilter = DashboardCardReview['state'] | 'none' + +export type DashboardFilters = { + projects: string[] + workspaceStatuses: string[] + reviewStates: DashboardReviewFilter[] +} + +export const EMPTY_DASHBOARD_FILTERS: DashboardFilters = { + projects: [], + workspaceStatuses: [], + reviewStates: [] +} + +export function activeDashboardFilterCount(filters: DashboardFilters): number { + return filters.projects.length + filters.workspaceStatuses.length + filters.reviewStates.length +} + +function cardSearchText(card: DashboardCard): string { + return [ + card.worktreeName, + card.repoName, + card.agentType, + card.conversationName, + card.task, + card.lastUserMessage, + card.lastAgentMessage, + card.askSummary, + card.review ? `#${card.review.number}` : '', + ...(card.subagents?.map((subagent) => subagent.name) ?? []) + ] + .filter(Boolean) + .join(' ') + .toLocaleLowerCase() +} + +export function filterDashboardCards( + cards: DashboardCard[], + query: string, + filters: DashboardFilters +): DashboardCard[] { + const normalizedQuery = query.trim().toLocaleLowerCase() + return cards.filter((card) => { + const reviewState = card.review?.state ?? (card.hasReview ? null : 'none') + return ( + (normalizedQuery.length === 0 || cardSearchText(card).includes(normalizedQuery)) && + (filters.projects.length === 0 || filters.projects.includes(card.repoId)) && + (filters.workspaceStatuses.length === 0 || + (card.workspaceStatusId !== undefined && + filters.workspaceStatuses.includes(card.workspaceStatusId))) && + (filters.reviewStates.length === 0 || + (reviewState !== null && filters.reviewStates.includes(reviewState))) + ) + }) +} + +export function toggleDashboardFilter(values: T[], value: T): T[] { + return values.includes(value) + ? values.filter((candidate) => candidate !== value) + : [...values, value] +} diff --git a/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.test.tsx b/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.test.tsx new file mode 100644 index 00000000000..c8445800013 --- /dev/null +++ b/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.test.tsx @@ -0,0 +1,71 @@ +// @vitest-environment happy-dom +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const updateSettings = vi.fn() + +vi.mock('@/store', () => ({ + useAppStore: ( + selector: (state: { + settings: { + experimentalAgentDashboardMode: 'in-window' + experimentalAgentDashboardShowIdle: boolean + } + updateSettings: typeof updateSettings + }) => unknown + ) => + selector({ + settings: { + experimentalAgentDashboardMode: 'in-window', + experimentalAgentDashboardShowIdle: false + }, + updateSettings + }) +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => {children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +import { AgentDashboardSettingsMenu } from './AgentDashboardSettingsMenu' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +afterEach(() => { + act(() => root?.unmount()) + root = null + container?.remove() + container = null + updateSettings.mockReset() +}) + +describe('AgentDashboardSettingsMenu', () => { + it('owns the idle-agent visibility setting', () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render() + }) + + const toggle = container.querySelector( + 'button[role="switch"][aria-label="Show idle agents"]' + ) + expect(toggle).not.toBeNull() + + act(() => toggle?.click()) + + expect(updateSettings).toHaveBeenCalledWith({ experimentalAgentDashboardShowIdle: true }) + }) +}) diff --git a/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx b/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx index 7e960e1f91a..1cc3b6ed03b 100644 --- a/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.tsx @@ -4,10 +4,11 @@ import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, + DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { SettingsSegmentedControl } from '../settings/SettingsFormControls' +import { SettingsSegmentedControl, SettingsSwitch } from '../settings/SettingsFormControls' import type { AgentDashboardMode } from '../../../../shared/types' import { translate } from '@/i18n/i18n' @@ -28,6 +29,7 @@ export function AgentDashboardSettingsMenu({ onOpenChange }: AgentDashboardSettingsMenuProps): React.JSX.Element { const mode = useAppStore((s) => s.settings?.experimentalAgentDashboardMode ?? 'in-window') + const showIdle = useAppStore((s) => s.settings?.experimentalAgentDashboardShowIdle === true) const updateSettings = useAppStore((s) => s.updateSettings) const handleModeChange = (next: AgentDashboardMode): void => { @@ -48,7 +50,7 @@ export function AgentDashboardSettingsMenu({ ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2a8120cabf7..63233daf1ab 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5703,7 +5703,7 @@ "agentDashboard": { "title": "Agent Dashboard", "description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out.", - "copy": "Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents and jump into their live terminals.", + "copy": "Adds an Agent Dashboard entry to the left sidebar. Monitor agents that need you, are working, or are done, with optional idle agents.", "toggleLabel": "Toggle Agent Dashboard", "modeLabel": "Open as", "modeCopy": "Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.", @@ -14262,12 +14262,16 @@ "attention": "Needs You", "working": "Working", "idle": "Idle", - "empty": "None" + "empty": "None", + "done": "Done" }, "title": "Agents", "total": "{{count}} total", "close": "Close dashboard", - "settings": "Agent Dashboard settings", + "settings": { + "showIdle": "Show idle agents", + "showIdleCopy": "Include agents that have gone quiet for 30 minutes without reporting completion. Hidden by default." + }, "settingsTooltip": "Board settings", "card": { "you": "You", @@ -14276,13 +14280,46 @@ "minutes": "{{count}}m", "hours": "{{count}}h", "days": "{{count}}d" - } + }, + "review": { + "open": "Open review", + "draft": "Draft review", + "merged": "Merged review", + "closed": "Closed review" + }, + "subagents_one": "{{count}} subagent", + "subagents_other": "{{count}} subagents" }, "terminal": { "closed": "No live terminal — this agent's pane has closed.", "focusWorktree": "Open worktree", "close": "Close" - } + }, + "filters": { + "remove": "Remove {{label}} filter", + "active": "Filters", + "clear": "Clear", + "review": { + "open": "Open", + "draft": "Draft", + "merged": "Merged", + "closed": "Closed", + "none": "No review" + }, + "reviewChip": "Review: {{state}}", + "label": "Filter", + "project": "Project", + "workspaceStatus": "Workspace status", + "reviewStatus": "PR / MR status", + "clearAll": "Clear all filters" + }, + "search": { + "placeholder": "Search worktree, project, or agent…", + "label": "Search agents", + "clear": "Clear search", + "results": "{{shown}} of {{total}} shown" + }, + "settingsLabel": "Agent Dashboard settings" }, "dashboard": { "sidebar": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 6aa511a9481..008fb2f67ca 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5680,7 +5680,7 @@ "agentDashboard": { "title": "Panel de agentes", "description": "Tablero Kanban para monitorear agentes en diferentes worktrees, en ventana o como ventana emergente.", - "copy": "Agrega una entrada del Panel de agentes a la barra lateral izquierda. Ábrelo para monitorear agentes en atención, trabajando y en pausa, y saltar a sus terminales en vivo.", + "copy": "Agrega una entrada del Panel de agentes a la barra lateral izquierda. Monitorea agentes que requieren tu atención, están trabajando o han terminado, con agentes inactivos opcionales.", "toggleLabel": "Alternar Panel de agentes", "modeLabel": "Abrir como", "modeCopy": "Muestra el tablero como un panel en ventana junto a la barra lateral o como una ventana emergente separada.", @@ -14239,7 +14239,8 @@ "attention": "Needs You", "working": "Working", "idle": "Idle", - "empty": "None" + "empty": "None", + "done": "Finalizado" }, "title": "Agents", "total": "{{count}} total", @@ -14250,7 +14251,15 @@ "minutes": "{{count}} min", "hours": "{{count}} h", "days": "{{count}} d" - } + }, + "review": { + "open": "Revisión abierta", + "draft": "Revisión en borrador", + "merged": "Revisión fusionada", + "closed": "Revisión cerrada" + }, + "subagents_one": "{{count}} subagente", + "subagents_other": "{{count}} subagentes" }, "terminal": { "closed": "No live terminal — this agent's pane has closed.", @@ -14258,8 +14267,36 @@ "close": "Close" }, "close": "Close dashboard", - "settings": "Configuración del Panel de agentes", - "settingsTooltip": "Configuración del tablero" + "settingsTooltip": "Configuración del tablero", + "filters": { + "remove": "Eliminar filtro {{label}}", + "active": "Filtros", + "clear": "Limpiar", + "review": { + "open": "Abierta", + "draft": "Borrador", + "merged": "Fusionada", + "closed": "Cerrada", + "none": "Sin revisión" + }, + "reviewChip": "Revisión: {{state}}", + "label": "Filtrar", + "project": "Proyecto", + "workspaceStatus": "Estado del espacio de trabajo", + "reviewStatus": "Estado de PR / MR", + "clearAll": "Limpiar todos los filtros" + }, + "search": { + "placeholder": "Buscar worktree, proyecto o agente…", + "label": "Buscar agentes", + "clear": "Borrar búsqueda", + "results": "{{shown}} de {{total}} mostrados" + }, + "settingsLabel": "Configuración del Panel de agentes", + "settings": { + "showIdle": "Mostrar agentes inactivos", + "showIdleCopy": "Incluye agentes que han permanecido inactivos durante 30 minutos sin informar que finalizaron. Ocultos de forma predeterminada." + } }, "dashboard": { "sidebar": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index cb2ded075df..cc33df6d859 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5665,7 +5665,7 @@ "agentDashboard": { "title": "エージェント ダッシュボード", "description": "ワークツリーエージェントを監視するカンバンボード。ウィンドウ内またはポップアウトで表示できます。", - "copy": "左サイドバーにエージェント ダッシュボードのエントリを追加します。開くと、注意中・作業中・アイドルのエージェントを監視し、ライブターミナルにジャンプできます。", + "copy": "左サイドバーにエージェント ダッシュボードのエントリを追加します。対応が必要なエージェント、作業中のエージェント、完了したエージェントを監視し、必要に応じてアイドル状態のエージェントも表示できます。", "toggleLabel": "エージェント ダッシュボードの切り替え", "modeLabel": "開き方", "modeCopy": "ダッシュボードをサイドバー横のウィンドウ内ボードまたは別のポップアウトウィンドウとして表示します。", @@ -14239,7 +14239,8 @@ "attention": "Needs You", "working": "Working", "idle": "Idle", - "empty": "None" + "empty": "None", + "done": "完了" }, "title": "Agents", "total": "{{count}} total", @@ -14250,7 +14251,15 @@ "minutes": "{{count}}分", "hours": "{{count}}時間", "days": "{{count}}日" - } + }, + "review": { + "open": "オープン中のレビュー", + "draft": "下書きのレビュー", + "merged": "マージ済みのレビュー", + "closed": "クローズ済みのレビュー" + }, + "subagents_one": "{{count}} 個のサブエージェント", + "subagents_other": "{{count}} 個のサブエージェント" }, "terminal": { "closed": "No live terminal — this agent's pane has closed.", @@ -14258,8 +14267,36 @@ "close": "Close" }, "close": "Close dashboard", - "settings": "エージェント ダッシュボード設定", - "settingsTooltip": "ボード設定" + "settingsTooltip": "ボード設定", + "filters": { + "remove": "{{label}} フィルターを削除", + "active": "フィルター", + "clear": "クリア", + "review": { + "open": "オープン", + "draft": "下書き", + "merged": "マージ済み", + "closed": "クローズ済み", + "none": "レビューなし" + }, + "reviewChip": "レビュー: {{state}}", + "label": "フィルター", + "project": "プロジェクト", + "workspaceStatus": "ワークスペースのステータス", + "reviewStatus": "PR / MR ステータス", + "clearAll": "すべてのフィルターをクリア" + }, + "search": { + "placeholder": "ワークツリー、プロジェクト、エージェントを検索…", + "label": "エージェントを検索", + "clear": "検索をクリア", + "results": "{{total}} 件中 {{shown}} 件を表示" + }, + "settingsLabel": "エージェント ダッシュボード設定", + "settings": { + "showIdle": "アイドル状態のエージェントを表示", + "showIdleCopy": "完了を報告せずに30分間停止しているエージェントを含めます。デフォルトでは非表示です。" + } }, "dashboard": { "sidebar": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index cbad58d68b9..98d27a306b5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5665,7 +5665,7 @@ "agentDashboard": { "title": "에이전트 대시보드", "description": "워크트리 전반에 걸친 에이전트를 모니터링하는 칸반 보드. 윈도우 내 또는 팝업으로 표시됩니다.", - "copy": "왼쪽 사이드바에 에이전트 대시보드 항목을 추가합니다. 열면 주의 중, 작업 중, 유휴 상태의 에이전트를 모니터링하고 라이브 터미널로 이동할 수 있습니다.", + "copy": "왼쪽 사이드바에 에이전트 대시보드 항목을 추가합니다. 사용자 확인이 필요한 에이전트, 작업 중인 에이전트, 완료된 에이전트를 모니터링하고 필요하면 유휴 에이전트도 표시합니다.", "toggleLabel": "에이전트 대시보드 전환", "modeLabel": "열기 형식", "modeCopy": "대시보드를 사이드바 옆 윈도우 내 보드 또는 별도의 팝업 윈도우로 표시합니다.", @@ -14239,7 +14239,8 @@ "attention": "Needs You", "working": "Working", "idle": "Idle", - "empty": "None" + "empty": "None", + "done": "완료" }, "title": "Agents", "total": "{{count}} total", @@ -14250,7 +14251,15 @@ "minutes": "{{count}}분", "hours": "{{count}}시간", "days": "{{count}}일" - } + }, + "review": { + "open": "열린 리뷰", + "draft": "초안 리뷰", + "merged": "병합된 리뷰", + "closed": "닫힌 리뷰" + }, + "subagents_one": "서브에이전트 {{count}}개", + "subagents_other": "서브에이전트 {{count}}개" }, "terminal": { "closed": "No live terminal — this agent's pane has closed.", @@ -14258,8 +14267,36 @@ "close": "Close" }, "close": "Close dashboard", - "settings": "에이전트 대시보드 설정", - "settingsTooltip": "보드 설정" + "settingsTooltip": "보드 설정", + "filters": { + "remove": "{{label}} 필터 제거", + "active": "필터", + "clear": "지우기", + "review": { + "open": "열림", + "draft": "초안", + "merged": "병합됨", + "closed": "닫힘", + "none": "리뷰 없음" + }, + "reviewChip": "리뷰: {{state}}", + "label": "필터", + "project": "프로젝트", + "workspaceStatus": "워크스페이스 상태", + "reviewStatus": "PR / MR 상태", + "clearAll": "모든 필터 지우기" + }, + "search": { + "placeholder": "워크트리, 프로젝트 또는 에이전트 검색…", + "label": "에이전트 검색", + "clear": "검색 지우기", + "results": "총 {{total}}개 중 {{shown}}개 표시" + }, + "settingsLabel": "에이전트 대시보드 설정", + "settings": { + "showIdle": "유휴 에이전트 표시", + "showIdleCopy": "완료를 보고하지 않은 채 30분 동안 조용한 에이전트를 포함합니다. 기본적으로 숨겨집니다." + } }, "dashboard": { "sidebar": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8077f97f6fe..5f38506f064 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5665,7 +5665,7 @@ "agentDashboard": { "title": "智能体仪表盘", "description": "用于监控跨工作树的智能体的看板,支持窗口内或弹出窗口显示。", - "copy": "在左侧边栏添加智能体仪表盘入口。打开它可以监控关注中、工作中和空闲的智能体,并跳转到其实时终端。", + "copy": "在左侧边栏添加智能体仪表盘入口。监控需要你处理、正在工作或已完成的智能体,并可选择显示空闲智能体。", "toggleLabel": "切换智能体仪表盘", "modeLabel": "打开方式", "modeCopy": "将仪表盘显示为侧边栏旁的窗口内看板,或单独的弹出窗口。", @@ -14239,7 +14239,8 @@ "attention": "Needs You", "working": "Working", "idle": "Idle", - "empty": "None" + "empty": "None", + "done": "已完成" }, "title": "Agents", "total": "{{count}} total", @@ -14250,7 +14251,15 @@ "minutes": "{{count}} 分钟", "hours": "{{count}} 小时", "days": "{{count}} 天" - } + }, + "review": { + "open": "开放的评审", + "draft": "草稿评审", + "merged": "已合并的评审", + "closed": "已关闭的评审" + }, + "subagents_one": "{{count}} 个子代理", + "subagents_other": "{{count}} 个子代理" }, "terminal": { "closed": "No live terminal — this agent's pane has closed.", @@ -14258,8 +14267,36 @@ "close": "Close" }, "close": "Close dashboard", - "settings": "智能体仪表盘设置", - "settingsTooltip": "看板设置" + "settingsTooltip": "看板设置", + "filters": { + "remove": "移除 {{label}} 筛选条件", + "active": "筛选条件", + "clear": "清除", + "review": { + "open": "开放", + "draft": "草稿", + "merged": "已合并", + "closed": "已关闭", + "none": "无评审" + }, + "reviewChip": "评审:{{state}}", + "label": "筛选", + "project": "项目", + "workspaceStatus": "工作区状态", + "reviewStatus": "PR / MR 状态", + "clearAll": "清除所有筛选条件" + }, + "search": { + "placeholder": "搜索工作树、项目或智能体…", + "label": "搜索智能体", + "clear": "清除搜索", + "results": "显示 {{shown}} / {{total}}" + }, + "settingsLabel": "智能体仪表盘设置", + "settings": { + "showIdle": "显示空闲智能体", + "showIdleCopy": "包括已安静 30 分钟且未报告完成的智能体。默认隐藏。" + } }, "dashboard": { "sidebar": { diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index b45717f9acb..88ea5c1282f 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -97,6 +97,7 @@ describe('getDefaultSettings', () => { it('keeps the agent dashboard popout disabled by default', () => { expect(getDefaultSettings('/tmp').experimentalAgentDashboardPopout).toBe(false) + expect(getDefaultSettings('/tmp').experimentalAgentDashboardShowIdle).toBe(false) }) it('routes fresh Codex profiles through the real-home rollout by default', () => {}) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 283deb15ad7..d017a0cdec5 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -351,6 +351,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { experimentalAgentDashboardPopout: false, // Why: in-window screen popover is the default surface; users opt into a separate pop-out window. experimentalAgentDashboardMode: 'in-window', + experimentalAgentDashboardShowIdle: false, experimentalActivityDefaultedOffForAllUsers: true, experimentalTerminalAttention: false, experimentalAgentHibernation: false, diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts index 1091a911b55..376760bff73 100644 --- a/src/shared/dashboard-snapshot.ts +++ b/src/shared/dashboard-snapshot.ts @@ -8,20 +8,31 @@ import type { RepoIcon } from './repo-icon' * Every field must be structured-clone-safe (no functions / class instances). */ -/** The three kanban columns. "Idle" is everything that isn't actively working - * and isn't blocking you — this includes explicitly-completed ('done') agents, - * which Orca only reports when a completion hook fires, so they're folded in - * rather than split into a separate, inconsistently-populated column. */ -export type DashboardBucket = 'attention' | 'working' | 'idle' +/** Agent lifecycle columns; idle is optional while completed agents remain visible. */ +export type DashboardBucket = 'attention' | 'working' | 'done' | 'idle' /** Column order shared by producer and pop-out so they never drift. */ -export const DASHBOARD_BUCKET_ORDER: readonly DashboardBucket[] = ['attention', 'working', 'idle'] +export const DASHBOARD_BUCKET_ORDER: readonly DashboardBucket[] = [ + 'attention', + 'working', + 'done', + 'idle' +] -/** Precise per-card state marker (drives AgentStateDot). Kept distinct from - * `bucket` so the "Needs You" column can still show amber (waiting/permission) - * vs red (blocked) dots. */ +/** Kept distinct from `bucket` so attention cards retain their precise dot state. */ export type DashboardCardDotState = 'working' | 'blocked' | 'waiting' | 'done' | 'idle' +export type DashboardCardReview = { + number: number + state: 'open' | 'closed' | 'merged' | 'draft' +} + +export type DashboardCardSubagent = { + id: string + name: string + dotState: DashboardCardDotState +} + export type DashboardCard = { /** Stable identity for React keys. */ paneKey: string @@ -44,6 +55,13 @@ export type DashboardCard = { leafId: string | null repoName: string worktreeName: string + workspaceStatusId?: string + workspaceStatusLabel?: string + workspaceStatusColor?: string + /** True when the workspace links a review whose live state is not cached yet. */ + hasReview?: boolean + review?: DashboardCardReview + subagents?: DashboardCardSubagent[] /** "Started … ago" display. */ startedAt: number /** When the agent last entered `done`, or null if it never finished. Drives @@ -64,9 +82,24 @@ export type DashboardCard = { conversationName?: string } +export type DashboardFilterOption = { + id: string + label: string + color?: string +} + +export type DashboardFilterOptions = { + projects: DashboardFilterOption[] + workspaceStatuses: DashboardFilterOption[] +} + export type DashboardSnapshot = { generatedAt: number cards: DashboardCard[] + showIdle?: boolean + /** Available filter dimensions are store-derived so zero-card projects and + * statuses remain selectable. Optional for preload-version compatibility. */ + filterOptions?: DashboardFilterOptions /** Icons for the repos the cards belong to. Keyed by repoId rather than * carried per card: image icons are data URLs up to 400KB, and the snapshot * is republished several times a second. Optional so a pop-out running @@ -77,6 +110,7 @@ export type DashboardSnapshot = { export const EMPTY_DASHBOARD_SNAPSHOT: DashboardSnapshot = { generatedAt: 0, cards: [], + filterOptions: { projects: [], workspaceStatuses: [] }, repoIconsByRepoId: {} } diff --git a/src/shared/types.ts b/src/shared/types.ts index dbe2c034010..b98f3d1ea1b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2976,6 +2976,8 @@ export type GlobalSettings = { experimentalAgentDashboardPopout?: boolean /** How the Agent Dashboard opens: an in-window companion board or a separate pop-out window. Defaults to in-window. */ experimentalAgentDashboardMode?: AgentDashboardMode + /** Includes stale quiet agents as a fourth Agent Dashboard column. */ + experimentalAgentDashboardShowIdle?: boolean /** One-shot migration guard for defaulting the Agents view off; later explicit opt-ins persist normally. */ experimentalActivityDefaultedOffForAllUsers?: boolean /** Experimental: persistent terminal-pane attention ring for bell + agent-completion events. Opt-in while tuning signal/noise. */