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
This commit is contained in:
Brennan Benson
2026-07-28 15:51:43 -07:00
committed by GitHub
parent 5753cf6c5c
commit 13c193a00a
33 changed files with 1894 additions and 155 deletions
@@ -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({
+82 -1
View File
@@ -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<string, unknown>
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<string, unknown>
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 `<img src>`, 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<string, unknown>
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<string, unknown>
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) &&
@@ -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 (
<span className="inline-flex h-[22px] items-center gap-1 rounded-full border border-border bg-muted/55 pr-1 pl-2 text-[11px]">
{label}
<button
type="button"
onClick={onRemove}
aria-label={translate('dashboardPopout.filters.remove', 'Remove {{label}} filter', {
label
})}
className="rounded-full text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<X className="size-3" />
</button>
</span>
)
}
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 (
<div className="flex shrink-0 flex-wrap items-center gap-1 border-b border-border px-3 py-2">
<span className="mr-0.5 text-[10px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate('dashboardPopout.filters.active', 'Filters')}
</span>
{filters.projects.map((id) => (
<ActiveChip
key={`project:${id}`}
label={projects.find((option) => option.id === id)?.label ?? id}
onRemove={() => onProjectToggle(id)}
/>
))}
{filters.workspaceStatuses.map((id) => (
<ActiveChip
key={`status:${id}`}
label={statuses.find((option) => option.id === id)?.label ?? id}
onRemove={() => onStatusToggle(id)}
/>
))}
{filters.reviewStates.map((id) => (
<ActiveChip
key={`review:${id}`}
label={reviewLabel(id)}
onRemove={() => onReviewToggle(id)}
/>
))}
<Button
variant="link"
size="xs"
onClick={onClear}
className="h-[22px] px-1 text-[11px] text-muted-foreground"
>
{translate('dashboardPopout.filters.clear', 'Clear')}
</Button>
</div>
)
}
@@ -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<string, number> {
const counts = new Map<string, number>()
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<string, FilterOption>()
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<string, FilterOption>()
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 <span className="ml-auto text-[11px] tabular-nums text-muted-foreground">{count}</span>
}
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 (
<>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => 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 ? (
<Button
variant="ghost"
size="icon-xs"
onClick={() => onQueryChange('')}
aria-label={translate('dashboardPopout.search.clear', 'Clear search')}
className="absolute top-1/2 right-0.5 -translate-y-1/2 text-muted-foreground"
>
<X className="size-3" />
</Button>
) : null}
</div>
{query || activeCount > 0 ? (
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{translate('dashboardPopout.search.results', '{{shown}} of {{total}} shown', {
shown: filteredCount,
total: cards.length
})}
</span>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="xs"
className={cn('h-7 gap-1.5 px-2 text-xs', activeCount > 0 && 'border-foreground/25')}
>
<Filter className="size-3" />
{translate('dashboardPopout.filters.label', 'Filter')}
{activeCount > 0 ? (
<span className="rounded-full bg-foreground px-1.5 py-px text-[10px] leading-none text-background">
{activeCount}
</span>
) : null}
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64" sideOffset={6}>
<DropdownMenuLabel>
{translate('dashboardPopout.filters.project', 'Project')}
</DropdownMenuLabel>
{projects.map((option) => (
<DropdownMenuCheckboxItem
key={option.id}
checked={filters.projects.includes(option.id)}
onCheckedChange={() => toggleProject(option.id)}
onSelect={(event) => event.preventDefault()}
>
<span className="truncate">{option.label}</span>
<OptionCount count={option.count} />
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>
{translate('dashboardPopout.filters.workspaceStatus', 'Workspace status')}
</DropdownMenuLabel>
{statuses.map((option) => {
const meta = getWorkspaceStatusVisualMeta({
id: option.id,
label: option.label,
color: option.color
})
return (
<DropdownMenuCheckboxItem
key={option.id}
checked={filters.workspaceStatuses.includes(option.id)}
onCheckedChange={() => toggleStatus(option.id)}
onSelect={(event) => event.preventDefault()}
>
<span className={cn('size-2 rounded-full', meta.swatch)} />
<span className="truncate">{option.label}</span>
<OptionCount count={option.count} />
</DropdownMenuCheckboxItem>
)
})}
<DropdownMenuSeparator />
<DropdownMenuLabel>
{translate('dashboardPopout.filters.reviewStatus', 'PR / MR status')}
</DropdownMenuLabel>
{REVIEW_OPTIONS.map((option) => (
<DropdownMenuCheckboxItem
key={option}
checked={filters.reviewStates.includes(option)}
onCheckedChange={() => toggleReview(option)}
onSelect={(event) => event.preventDefault()}
>
<span>{reviewStateLabel(option)}</span>
<OptionCount count={reviewCounts.get(option) ?? 0} />
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={activeCount === 0}
onSelect={clearFilters}
className="text-muted-foreground"
>
<X className="size-3.5" />
{translate('dashboardPopout.filters.clearAll', 'Clear all filters')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{activeCount > 0 ? (
<AgentDashboardFilterChips
filters={filters}
projects={projects}
statuses={statuses}
reviewLabel={reviewLabel}
onProjectToggle={toggleProject}
onStatusToggle={toggleStatus}
onReviewToggle={toggleReview}
onClear={clearFilters}
/>
) : null}
</>
)
}
@@ -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>): DashboardCard {
function renderBoard(
cards: DashboardCard[],
repoIconsByRepoId?: Record<string, RepoIcon | null>
options: {
showIdle?: boolean
repoIconsByRepoId?: Record<string, RepoIcon | null>
filterOptions?: DashboardFilterOptions
} = {}
): void {
const snapshot: DashboardSnapshot = { generatedAt: 1, cards, repoIconsByRepoId }
const snapshot: DashboardSnapshot = { generatedAt: 1, cards, ...options }
render(<AgentKanbanBoard snapshot={snapshot} />)
}
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(<AgentKanbanBoard snapshot={{ generatedAt: 1, cards: [agent] }} />)
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(<AgentKanbanBoard snapshot={{ generatedAt: 2, cards: [moved] }} />)
@@ -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(<AgentKanbanBoard snapshot={{ generatedAt: 1, cards: [agent] }} />)
// unseen comes straight from the snapshot (the shared ack map).
expect(screen.getByTestId('card').dataset.unseen).toBe('true')
@@ -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<DashboardBucket, Dashboar
const grouped: Record<DashboardBucket, DashboardCard[]> = {
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<DashboardFilters>(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({
</h1>
<span className="text-[11px] text-muted-foreground">
{translate('dashboardPopout.total', '{{count}} total', {
count: snapshot.cards.length
count: visibleCards.length
})}
</span>
{headerActions || onClose ? (
@@ -223,13 +247,19 @@ export function AgentKanbanBoard({
</div>
) : null}
</div>
<AgentDashboardToolbar
cards={visibleCards}
filterOptions={snapshot.filterOptions}
filteredCount={filteredCards.length}
query={query}
onQueryChange={setQuery}
filters={filters}
onFiltersChange={setFilters}
/>
<div className="scrollbar-sleek flex min-h-0 flex-1 overflow-x-auto p-3">
{/* 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. */}
<div className="mx-auto flex w-full max-w-[1280px] gap-3">
{DASHBOARD_BUCKET_ORDER.map((bucket) => (
{visibleBuckets.map((bucket) => (
<KanbanColumn
key={bucket}
bucket={bucket}
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
@@ -102,26 +102,83 @@ describe('AgentKanbanCard', () => {
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(
<TooltipProvider>
@@ -185,7 +244,7 @@ describe('AgentKanbanCard', () => {
rerender(
<TooltipProvider>
<AgentKanbanCard
card={{ ...initial }}
card={{ ...initial, subagents: initial.subagents?.map((subagent) => ({ ...subagent })) }}
repoIcon={{ ...repoIcon }}
now={62_000}
onOpenTerminal={onOpenTerminal}
@@ -197,7 +256,7 @@ describe('AgentKanbanCard', () => {
rerender(
<TooltipProvider>
<AgentKanbanCard
card={{ ...initial }}
card={{ ...initial, subagents: initial.subagents?.map((subagent) => ({ ...subagent })) }}
repoIcon={{ ...repoIcon }}
now={121_500}
onOpenTerminal={onOpenTerminal}
@@ -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 (
<span
role="img"
aria-label={`${title} #${card.review.number}`}
className={cn(
'inline-flex shrink-0 items-center gap-0.5 rounded-full border px-1 py-px text-[10px] leading-none tabular-nums',
presentation.className
)}
>
<Icon className="size-2.5" aria-hidden />#{card.review.number}
</span>
)
}
/** 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 (
<button
type="button"
onClick={() => onOpenTerminal(card)}
<div
// Why: a stable per-agent view-transition-name lets the browser morph
// the card from its old column to its new one when its bucket changes.
// paneKey has ':'/'/' which aren't valid in a custom-ident, so slugify.
style={{ viewTransitionName: `agentcard-${card.paneKey.replace(/[^a-zA-Z0-9]/g, '-')}` }}
className={cn(
'group flex w-full flex-col gap-1.5 rounded-lg border p-2.5 text-left',
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'group flex w-full flex-col gap-1.5 rounded-lg border p-2.5 text-left transition-colors',
needsYou
? 'border-amber-500/40 bg-amber-500/[0.06] hover:border-amber-500/60 hover:bg-amber-500/10'
: isDone
@@ -129,62 +234,102 @@ export const AgentKanbanCard = memo(
: 'border-border/60 bg-card hover:border-border hover:bg-accent/40'
)}
>
<div className="flex items-center gap-1.5">
{/* Why: a bare <svg> flex item shrinks with the row — long worktree names squashed the icon. */}
<span className="inline-flex shrink-0">
<AgentIcon agent={agentTypeToIconAgent(card.agentType)} size={14} />
</span>
<span
// Why: same unvisited treatment as the sidebar's DashboardAgentRow —
// bold+bright until acked, normal+muted after — so both surfaces
// read identically (the ack map is shared).
className={cn(
'truncate text-[12.5px]',
card.unseen ? 'font-semibold text-foreground' : 'font-normal text-muted-foreground'
)}
>
{heading}
</span>
{/* The summary pill already carries the attention glyph. */}
{card.askSummary ? null : <AgentStateDot state={card.dotState} className="ml-auto" />}
</div>
<button
type="button"
onClick={() => onOpenTerminal(card)}
className="flex w-full flex-col gap-1.5 text-left focus-visible:rounded-md focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<div className="flex w-full items-center gap-1.5">
{/* Why: a bare <svg> flex item shrinks with the row. */}
<span className="inline-flex shrink-0">
<AgentIcon agent={agentTypeToIconAgent(card.agentType)} size={14} />
</span>
<span
className={cn(
'truncate text-[12.5px]',
card.unseen ? 'font-semibold text-foreground' : 'font-normal text-muted-foreground'
)}
>
{heading}
</span>
{card.askSummary ? null : <AgentStateDot state={card.dotState} className="ml-auto" />}
</div>
{card.lastUserMessage || card.lastAgentMessage ? (
<div className="flex flex-col gap-0.5">
{card.lastUserMessage ? (
<div className="line-clamp-1 text-[11px] leading-snug text-muted-foreground">
{/* Why: plain "You" again — the session's name now heads the card. */}
<span className="font-medium text-foreground/45">
{translate('dashboardPopout.card.you', 'You')}
</span>{' '}
{card.lastUserMessage}
{card.lastUserMessage || card.lastAgentMessage ? (
<div className="flex w-full flex-col gap-0.5">
{card.lastUserMessage ? (
<div className="line-clamp-1 text-[11px] leading-snug text-muted-foreground">
<span className="font-medium text-foreground/45">
{translate('dashboardPopout.card.you', 'You')}
</span>{' '}
{card.lastUserMessage}
</div>
) : null}
{card.lastAgentMessage ? (
<div className="line-clamp-2 text-xs leading-snug text-foreground/90">
<span className="font-medium text-foreground/45">
{formatAgentTypeLabel(card.agentType)}
</span>{' '}
{card.lastAgentMessage}
</div>
) : null}
</div>
) : card.task ? (
<div className="line-clamp-2 w-full text-xs leading-snug text-foreground/90">
{card.task}
</div>
) : null}
{card.askSummary ? (
<div className="flex w-full items-start gap-1 rounded-md bg-amber-500/15 px-1.5 py-1 text-[11px] text-amber-600 ring-1 ring-inset ring-amber-500/25 dark:text-amber-400">
<MessageCircleQuestion className="mt-px size-3 shrink-0" aria-hidden />
<span className="line-clamp-2">{card.askSummary}</span>
</div>
) : null}
</button>
{card.subagents?.length ? (
<>
<button
type="button"
aria-expanded={subagentsOpen}
onClick={() => setSubagentsOpen((open) => !open)}
className="flex items-center gap-1 text-[10.5px] text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<ChevronRight
className={cn('size-3 transition-transform', subagentsOpen && 'rotate-90')}
/>
{formatSubagentCount(card.subagents.length)}
</button>
{subagentsOpen ? (
<div className="ml-1 flex flex-col gap-1 border-l border-border pl-2">
{card.subagents.map((subagent) => (
<div
key={subagent.id}
className="flex items-center gap-1.5 py-0.5 text-[11px] text-muted-foreground"
>
<AgentStateDot state={subagent.dotState} />
<span className="truncate">{subagent.name}</span>
</div>
))}
</div>
) : null}
{card.lastAgentMessage ? (
<div className="line-clamp-2 text-xs leading-snug text-foreground/90">
<span className="font-medium text-foreground/45">
{formatAgentTypeLabel(card.agentType)}
</span>{' '}
{card.lastAgentMessage}
</div>
) : null}
</div>
) : card.task ? (
<div className="line-clamp-2 text-xs leading-snug text-foreground/90">{card.task}</div>
</>
) : 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 ? (
<div className="flex items-start gap-1 rounded-md bg-amber-500/15 px-1.5 py-1 text-[11px] text-amber-600 ring-1 ring-inset ring-amber-500/25 dark:text-amber-400">
<MessageCircleQuestion className="mt-px size-3 shrink-0" aria-hidden />
<span className="line-clamp-2">{card.askSummary}</span>
</div>
) : null}
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
{/* 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. */}
<button
type="button"
onClick={() => onOpenTerminal(card)}
className="flex w-full items-center gap-2 rounded-md text-left text-[11px] text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
{workspaceStatusMeta ? (
<span
role="img"
aria-label={card.workspaceStatusLabel}
className={cn('size-2 shrink-0 rounded-full', workspaceStatusMeta.swatch)}
title={card.workspaceStatusLabel}
/>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<span
@@ -199,13 +344,14 @@ export const AgentKanbanCard = memo(
</TooltipContent>
</Tooltip>
{worktreeInFooter ? <span className="truncate">{card.worktreeName}</span> : null}
<ReviewPill card={card} />
{displayTimestamp(card) > 0 ? (
<span className="ml-auto shrink-0 pl-1 tabular-nums">
{formatStartedAgo(displayTimestamp(card), now)}
</span>
) : null}
</div>
</button>
</button>
</div>
)
},
(previous, next) =>
@@ -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>): 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'])
})
})
@@ -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<T extends string>(values: T[], value: T): T[] {
return values.includes(value)
? values.filter((candidate) => candidate !== value)
: [...values, value]
}
@@ -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 }) => <div>{children}</div>,
DropdownMenuSeparator: () => <hr />,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>,
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(<AgentDashboardSettingsMenu onSwitchToPopout={vi.fn()} onOpenChange={vi.fn()} />)
})
const toggle = container.querySelector<HTMLButtonElement>(
'button[role="switch"][aria-label="Show idle agents"]'
)
expect(toggle).not.toBeNull()
act(() => toggle?.click())
expect(updateSettings).toHaveBeenCalledWith({ experimentalAgentDashboardShowIdle: true })
})
})
@@ -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({
<Button
variant="ghost"
size="icon-xs"
aria-label={translate('dashboardPopout.settings', 'Agent Dashboard settings')}
aria-label={translate('dashboardPopout.settingsLabel', 'Agent Dashboard settings')}
className="text-muted-foreground"
>
<Settings className="size-3.5" />
@@ -104,6 +106,27 @@ export function AgentDashboardSettingsMenu({
]}
/>
</div>
<DropdownMenuSeparator />
<div className="flex items-start justify-between gap-3 rounded-md px-1.5 py-1.5">
<span className="min-w-0 space-y-0.5">
<span className="block text-[12px] font-medium leading-4 text-foreground">
{translate('dashboardPopout.settings.showIdle', 'Show idle agents')}
</span>
<span className="block text-[11px] leading-4 text-muted-foreground">
{translate(
'dashboardPopout.settings.showIdleCopy',
'Include agents that have gone quiet for 30 minutes without reporting completion. Hidden by default.'
)}
</span>
</span>
<SettingsSwitch
checked={showIdle}
onChange={() => {
void updateSettings({ experimentalAgentDashboardShowIdle: !showIdle })
}}
ariaLabel={translate('dashboardPopout.settings.showIdle', 'Show idle agents')}
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
)
@@ -89,6 +89,38 @@ function baseState(overrides: Partial<DashboardSnapshotState>): DashboardSnapsho
}
describe('buildDashboardSnapshot', () => {
it('publishes project and workspace-status filters without agent cards', () => {
const snapshot = buildDashboardSnapshot(
baseState({
repos: [
{ id: 'r1', path: '/r1', displayName: 'Repo One', badgeColor: '#000' },
{ id: 'r2', path: '/r2', displayName: 'Repo Two', badgeColor: '#000' }
],
worktreesByRepo: {
r1: [worktree()],
r2: [worktree('w2', 'wt-two')]
},
workspaceStatuses: [
{ id: 'planned', label: 'Planned', color: 'neutral' },
{ id: 'active', label: 'Active', color: 'blue' }
]
} as unknown as Partial<DashboardSnapshotState>),
NOW
)
expect(snapshot.cards).toEqual([])
expect(snapshot.filterOptions).toEqual({
projects: [
{ id: 'r1', label: 'Repo One' },
{ id: 'r2', label: 'Repo Two' }
],
workspaceStatuses: [
{ id: 'planned', label: 'Planned', color: 'neutral' },
{ id: 'active', label: 'Active', color: 'blue' }
]
})
})
it('maps a live working agent to the working bucket with a resolved ptyId', () => {
const snapshot = buildDashboardSnapshot(
baseState({
@@ -255,7 +287,7 @@ describe('buildDashboardSnapshot', () => {
expect(snapshot.cards[0].dotState).toBe('idle')
})
it('folds retained done agents into the idle bucket, keeping a done dot', () => {
it('routes retained done agents to the done bucket', () => {
const donePaneKey = makePaneKey(TAB_ID, GONE_LEAF_ID)
const snapshot = buildDashboardSnapshot(
baseState({
@@ -273,7 +305,77 @@ describe('buildDashboardSnapshot', () => {
)
const done = snapshot.cards.find((c) => c.dotState === 'done')
expect(done).toBeDefined()
expect(done?.bucket).toBe('idle')
expect(done?.bucket).toBe('done')
})
it('includes collapsed subagents and workspace status metadata on the parent card', () => {
const snapshot = buildDashboardSnapshot(
baseState({
workspaceStatuses: [
{ id: 'todo', label: 'Todo', color: 'neutral' },
{ id: 'reviewing', label: 'Reviewing', color: 'emerald' }
],
worktreesByRepo: {
r1: [{ ...worktree(), workspaceStatus: 'reviewing' }]
},
agentStatusByPaneKey: {
[PANE_KEY]: entry({
subagents: [
{
id: 'child-1',
state: 'working',
startedAt: NOW - 1000,
description: 'Review loop'
}
]
})
}
}),
NOW
)
expect(snapshot.cards).toHaveLength(1)
expect(snapshot.cards[0]).toMatchObject({
workspaceStatusId: 'reviewing',
workspaceStatusLabel: 'Reviewing',
workspaceStatusColor: 'emerald',
subagents: [{ name: 'Review loop', dotState: 'working' }]
})
})
it('skips card-only context for count snapshots', () => {
let linkedReviewReads = 0
const countWorktree = worktree()
Object.defineProperty(countWorktree, 'linkedPR', {
enumerable: true,
get: () => {
linkedReviewReads += 1
return null
}
})
const snapshot = buildDashboardSnapshot(
baseState({
worktreesByRepo: { r1: [countWorktree] },
agentStatusByPaneKey: { [PANE_KEY]: entry({}) }
}),
NOW,
{ includeCardDetails: false, includeFilterOptions: false }
)
expect(snapshot.cards[0].workspaceStatusId).toBeUndefined()
expect(snapshot.cards[0].subagents).toBeUndefined()
expect(linkedReviewReads).toBe(0)
})
it('relays the idle-column setting in the serialized snapshot', () => {
const snapshot = buildDashboardSnapshot(
baseState({
settings: { experimentalAgentDashboardShowIdle: true } as never
}),
NOW
)
expect(snapshot.showIdle).toBe(true)
})
it('attaches batched runtime orchestration metadata to dashboard rows', () => {
@@ -3,9 +3,11 @@ import type {
DashboardBucket,
DashboardCard,
DashboardCardDotState,
DashboardCardSubagent,
DashboardSnapshot
} from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { DEFAULT_WORKSPACE_STATUSES } from '../../../../shared/workspace-statuses'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
@@ -29,6 +31,10 @@ import {
selectLivePtyIdsForWorktree,
selectRuntimePaneTitlesForWorktree
} from '../sidebar/worktree-card-status-inputs'
import {
resolveDashboardCardContext,
type DashboardCardContextState
} from './dashboard-card-context'
/** The store slices the snapshot builder reads. Kept as a Pick so unit tests
* can pass a partial store without constructing the whole AppState. */
@@ -46,15 +52,15 @@ export type DashboardSnapshotState = Pick<
| 'runtimePaneTitlesByTabId'
| 'acknowledgedAgentsByPaneKey'
| 'settings'
>
> &
DashboardCardContextState
function bucketForState(state: DashboardAgentRow['state']): DashboardBucket {
switch (state) {
case 'working':
return 'working'
// 'done' folds into Idle — it's only reported when a completion hook fires,
// so it's not a reliable standalone column. The card keeps a done dot.
case 'done':
return 'done'
case 'idle':
return 'idle'
// blocked | waiting — the agent needs the user.
@@ -100,10 +106,12 @@ function rowConversationName(
*/
export function buildDashboardSnapshot(
state: DashboardSnapshotState,
now: number
now: number,
options: { includeCardDetails?: boolean; includeFilterOptions?: boolean } = {}
): DashboardSnapshot {
const cards: DashboardCard[] = []
const repoIconsByRepoId: Record<string, RepoIcon | null> = {}
const includeCardDetails = options.includeCardDetails !== false
const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true
const activeWorktrees: {
repo: AppState['repos'][number]
@@ -117,6 +125,22 @@ export function buildDashboardSnapshot(
}
}
}
const filterOptions =
options.includeFilterOptions === false
? undefined
: {
projects: [...new Map(activeWorktrees.map(({ repo }) => [repo.id, repo])).values()].map(
(repo) => ({ id: repo.id, label: repo.displayName })
),
workspaceStatuses: (state.workspaceStatuses && state.workspaceStatuses.length > 0
? state.workspaceStatuses
: DEFAULT_WORKSPACE_STATUSES
).map((status) => ({
id: status.id,
label: status.label,
color: status.color
}))
}
let singletonOrchestration: ReturnType<typeof selectRuntimeAgentOrchestrationForWorktree> | null =
null
let orchestrationByWorktree: ReturnType<typeof selectRuntimeAgentOrchestrationBatch> | null = null
@@ -166,6 +190,37 @@ export function buildDashboardSnapshot(
now
})
)
const subagentsByParentPaneKey = includeCardDetails
? new Map<string, DashboardCardSubagent[]>()
: undefined
if (subagentsByParentPaneKey) {
for (const row of rows) {
if (row.rowSource !== 'subagent') {
continue
}
const parentPaneKey = row.entry.orchestration?.parentPaneKey
if (!parentPaneKey) {
continue
}
const subagent: DashboardCardSubagent = {
id: row.paneKey,
name:
nonEmpty(row.entry.orchestration?.displayName) ??
nonEmpty(row.entry.prompt) ??
row.agentType,
dotState: row.state
}
const existing = subagentsByParentPaneKey.get(parentPaneKey)
if (existing) {
existing.push(subagent)
} else {
subagentsByParentPaneKey.set(parentPaneKey, [subagent])
}
}
}
const context = includeCardDetails
? resolveDashboardCardContext(state, repo, worktree)
: undefined
for (const row of rows) {
// Child rows have no pane of their own; the board lists top-level agents.
@@ -208,6 +263,12 @@ export function buildDashboardSnapshot(
leafId,
repoName: repo.displayName,
worktreeName: worktree.displayName,
workspaceStatusId: context?.workspaceStatus.id,
workspaceStatusLabel: context?.workspaceStatus.label,
workspaceStatusColor: context?.workspaceStatus.color,
hasReview: context ? context.hasReview || context.review !== undefined : undefined,
review: context?.review,
subagents: subagentsByParentPaneKey?.get(row.paneKey),
lastUserMessage: isTitleDerived ? undefined : nonEmpty(row.entry.prompt),
lastAgentMessage: isTitleDerived ? undefined : nonEmpty(row.entry.lastAssistantMessage),
startedAt: row.startedAt,
@@ -224,5 +285,11 @@ export function buildDashboardSnapshot(
}
}
return { generatedAt: now, cards, repoIconsByRepoId }
return {
generatedAt: now,
cards,
showIdle: state.settings?.experimentalAgentDashboardShowIdle === true,
filterOptions,
repoIconsByRepoId
}
}
@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { PRInfo, Repo, Worktree } from '../../../../shared/types'
import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
import {
resolveDashboardCardContext,
type DashboardCardContextState
} from './dashboard-card-context'
const repo: Repo = {
id: 'repo-1',
path: '/repo',
displayName: 'Repo',
badgeColor: '#fff',
addedAt: 1,
kind: 'git'
}
function worktree(overrides: Partial<Worktree> = {}): Worktree {
return {
id: 'worktree-1',
repoId: repo.id,
path: '/repo/worktree',
head: 'current-head',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false,
displayName: 'feature',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
function review(overrides: Partial<HostedReviewInfo> = {}): HostedReviewInfo {
return {
provider: 'bitbucket',
number: 77,
title: 'Review',
state: 'open',
url: 'https://example.test/review/77',
status: 'success',
updatedAt: '2026-01-01T00:00:00.000Z',
mergeable: 'MERGEABLE',
...overrides
}
}
function state(
cachedReview: HostedReviewInfo,
linkedReviewHintKey: string
): DashboardCardContextState {
const cacheKey = getHostedReviewCacheKey(repo.path, 'feature', null, repo.id, null, null, true)
return {
settings: null,
hostedReviewCache: {
[cacheKey]: { data: cachedReview, fetchedAt: 1, linkedReviewHintKey }
},
prCache: {}
}
}
describe('resolveDashboardCardContext', () => {
it.each([
['bitbucket', { linkedBitbucketPR: 77 }],
['azure-devops', { linkedAzureDevOpsPR: 77 }],
['gitea', { linkedGiteaPR: 77 }]
] as const)('uses valid cached %s review metadata', (provider, link) => {
expect(
resolveDashboardCardContext(
state(review({ provider }), `${provider}:77`),
repo,
worktree(link)
).review
).toEqual({ number: 77, state: 'open' })
})
it('keeps validated GitHub PR cache metadata as a fallback', () => {
const pr: PRInfo = {
number: 42,
title: 'GitHub review',
state: 'draft',
url: 'https://example.test/pull/42',
checksStatus: 'pending',
updatedAt: '2026-01-01T00:00:00.000Z',
mergeable: 'UNKNOWN'
}
const cacheKey = getGitHubPRCacheKey(repo.path, repo.id, 'feature', null, null, null, true)
expect(
resolveDashboardCardContext(
{
settings: null,
hostedReviewCache: {},
prCache: { [cacheKey]: { data: pr, fetchedAt: 1 } }
},
repo,
worktree({ linkedPR: 42 })
).review
).toEqual({ number: 42, state: 'draft' })
})
it('rejects cached review metadata from the previous linked review', () => {
expect(
resolveDashboardCardContext(
state(review({ number: 12 }), 'bitbucket:12'),
repo,
worktree({ linkedBitbucketPR: 13 })
).review
).toBeUndefined()
})
it('rejects a merged review after the worktree head advances', () => {
expect(
resolveDashboardCardContext(
state(review({ state: 'merged', headSha: 'merged-head' }), ''),
repo,
worktree()
).review
).toBeUndefined()
})
})
@@ -0,0 +1,94 @@
import { branchName } from '@/lib/git-utils'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
import type { AppState } from '@/store/types'
import type { DashboardCardReview } from '../../../../shared/dashboard-snapshot'
import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github'
import { isPositiveHostedReviewNumber } from '../../../../shared/hosted-review'
import type { Repo, Worktree, WorkspaceStatusDefinition } from '../../../../shared/types'
import {
DEFAULT_WORKSPACE_STATUSES,
getWorkspaceStatus
} from '../../../../shared/workspace-statuses'
import {
canUseParentPrChecksGitHubPRCacheEntry,
getParentPrChecksGitHubPRCacheEntry
} from '../right-sidebar/parent-pr-checks-github-pr-cache'
import { canUseParentPrChecksHostedReviewCacheEntry } from '../right-sidebar/parent-pr-checks-hosted-review-cache'
export type DashboardCardContextState = Partial<
Pick<AppState, 'hostedReviewCache' | 'prCache' | 'settings' | 'workspaceStatuses'>
>
export type DashboardCardContext = {
workspaceStatus: WorkspaceStatusDefinition
hasReview: boolean
review?: DashboardCardReview
}
function hasLinkedReview(worktree: Worktree): boolean {
return [
worktree.linkedPR,
worktree.linkedGitLabMR,
worktree.linkedBitbucketPR,
worktree.linkedAzureDevOpsPR,
worktree.linkedGiteaPR
].some(isPositiveHostedReviewNumber)
}
function resolveReview(
state: DashboardCardContextState,
repo: Repo,
worktree: Worktree
): DashboardCardReview | undefined {
if (!state.hostedReviewCache || !state.prCache || repo.kind === 'folder') {
return undefined
}
const branch = branchName(worktree.branch)
const hostedReviewEntry =
state.hostedReviewCache[
getHostedReviewCacheKey(
repo.path,
branch,
state.settings,
repo.id,
repo.connectionId,
repo.executionHostId,
true
)
]
const hostedReview = hostedReviewEntry?.data
if (
hostedReview &&
canUseParentPrChecksHostedReviewCacheEntry(worktree, hostedReview, hostedReviewEntry)
) {
return { number: hostedReview.number, state: hostedReview.state }
}
const prEntry = getParentPrChecksGitHubPRCacheEntry({
prCache: state.prCache,
repo,
branch,
settings: state.settings ?? null
})
const review = canUseParentPrChecksGitHubPRCacheEntry(worktree, prEntry, hostedReviewEntry)
? hostedReviewInfoFromGitHubPRInfo(prEntry.data)
: undefined
return review ? { number: review.number, state: review.state } : undefined
}
export function resolveDashboardCardContext(
state: DashboardCardContextState,
repo: Repo,
worktree: Worktree
): DashboardCardContext {
const statuses =
state.workspaceStatuses && state.workspaceStatuses.length > 0
? state.workspaceStatuses
: DEFAULT_WORKSPACE_STATUSES
const workspaceStatusId = getWorkspaceStatus(worktree, statuses)
return {
workspaceStatus:
statuses.find((status) => status.id === workspaceStatusId) ?? DEFAULT_WORKSPACE_STATUSES[0],
review: resolveReview(state, repo, worktree),
hasReview: hasLinkedReview(worktree)
}
}
@@ -6,7 +6,7 @@ import { buildDashboardSnapshot } from './build-dashboard-snapshot'
export type AgentBucketCounts = Record<DashboardBucket, number>
const EMPTY_COUNTS: AgentBucketCounts = { attention: 0, working: 0, idle: 0 }
const EMPTY_COUNTS: AgentBucketCounts = { attention: 0, working: 0, done: 0, idle: 0 }
/**
* Per-state agent counts for the sidebar dashboard entry, derived from the same
@@ -61,12 +61,13 @@ export function useAgentBucketCounts(): AgentBucketCounts {
// generated-title gate is moot and the sidebar stays off settings.
settings: null
},
Date.now()
Date.now(),
{ includeCardDetails: false, includeFilterOptions: false }
)
if (snapshot.cards.length === 0) {
return EMPTY_COUNTS
}
const counts: AgentBucketCounts = { attention: 0, working: 0, idle: 0 }
const counts: AgentBucketCounts = { attention: 0, working: 0, done: 0, idle: 0 }
for (const card of snapshot.cards) {
counts[card.bucket] += 1
}
@@ -120,7 +120,11 @@ describe('useDashboardPopoutBridge', () => {
'terminalLayoutsByTabId',
'ptyIdsByTabId',
'runtimePaneTitlesByTabId',
'acknowledgedAgentsByPaneKey'
'acknowledgedAgentsByPaneKey',
'hostedReviewCache',
'prCache',
'settings',
'workspaceStatuses'
] as const
for (const key of referenceInputs) {
expect(
@@ -26,8 +26,11 @@ export function dashboardSnapshotInputsChanged(
state.ptyIdsByTabId !== previousState.ptyIdsByTabId ||
state.runtimePaneTitlesByTabId !== previousState.runtimePaneTitlesByTabId ||
state.acknowledgedAgentsByPaneKey !== previousState.acknowledgedAgentsByPaneKey ||
// Why: tabAutoGenerateTitle decides whether cards may show generated names.
state.hostedReviewCache !== previousState.hostedReviewCache ||
state.prCache !== previousState.prCache ||
// Why: settings controls idle visibility and generated conversation names.
state.settings !== previousState.settings ||
state.workspaceStatuses !== previousState.workspaceStatuses ||
// Why: freshness can change a bucket without replacing any backing map.
state.agentStatusEpoch !== previousState.agentStatusEpoch
)
@@ -23,8 +23,11 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId)
const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey)
// Why: gates generated tab titles in the cards' conversation names.
const hostedReviewCache = useAppStore((s) => s.hostedReviewCache)
const prCache = useAppStore((s) => s.prCache)
// Why: controls idle visibility and gates generated conversation names.
const settings = useAppStore((s) => s.settings)
const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
// Why: freshness can flip a bucket without any backing map changing; the epoch
// ticks on the freshness boundary so the memo re-derives stale-decayed cards.
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
@@ -46,7 +49,10 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
ptyIdsByTabId,
runtimePaneTitlesByTabId,
acknowledgedAgentsByPaneKey,
settings
hostedReviewCache,
prCache,
settings,
workspaceStatuses
},
Date.now()
),
@@ -63,7 +69,10 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
ptyIdsByTabId,
runtimePaneTitlesByTabId,
acknowledgedAgentsByPaneKey,
hostedReviewCache,
prCache,
settings,
workspaceStatuses,
agentStatusEpoch
]
)
@@ -16,6 +16,7 @@ export function AgentDashboardExperimentalSetting({
}: AgentDashboardExperimentalSettingProps): React.JSX.Element {
const enabled = settings.experimentalAgentDashboardPopout === true
const mode = settings.experimentalAgentDashboardMode ?? 'in-window'
const showIdle = settings.experimentalAgentDashboardShowIdle === true
return (
<SearchableSetting
@@ -42,7 +43,7 @@ export function AgentDashboardExperimentalSetting({
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.ExperimentalPane.agentDashboard.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.'
'Adds an Agent Dashboard entry to the left sidebar. Monitor agents that need you, are working, or are done, with optional idle agents.'
)}
</p>
</div>
@@ -56,7 +57,7 @@ export function AgentDashboardExperimentalSetting({
/>
</div>
{enabled ? (
<div className="ml-4 border-l border-border pl-4">
<div className="ml-4 space-y-3 border-l border-border pl-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-0.5">
<Label>
@@ -98,6 +99,22 @@ export function AgentDashboardExperimentalSetting({
]}
/>
</div>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-0.5">
<Label>{translate('dashboardPopout.settings.showIdle', 'Show idle agents')}</Label>
<p className="text-xs text-muted-foreground">
{translate(
'dashboardPopout.settings.showIdleCopy',
'Include agents that have gone quiet for 30 minutes without reporting completion. Hidden by default.'
)}
</p>
</div>
<SettingsSwitch
checked={showIdle}
onChange={() => updateSettings({ experimentalAgentDashboardShowIdle: !showIdle })}
ariaLabel={translate('dashboardPopout.settings.showIdle', 'Show idle agents')}
/>
</div>
</div>
) : null}
</SearchableSetting>
@@ -142,7 +142,7 @@ describe('ExperimentalPane', () => {
expect(settings.experimentalAgentDashboardPopout).toBe(false)
expect(markup).toContain('Agent Dashboard')
expect(markup).toContain('monitor attention, working, and idle agents')
expect(markup).toContain('Monitor agents that need you, are working, or are done')
expect(getExperimentalPaneSearchEntries().map((entry) => entry.title)).toContain(
'Agent Dashboard'
)
@@ -166,6 +166,31 @@ describe('ExperimentalPane', () => {
root.unmount()
})
it('exposes idle-agent visibility for pop-out dashboards', async () => {
const updateSettings = vi.fn()
const settings = {
...getDefaultSettings('/tmp'),
experimentalAgentDashboardPopout: true
}
const { root, container } = await renderExperimentalPane({
settings,
updateSettings
})
const idleSwitch = container.querySelector<HTMLButtonElement>(
'#experimental-agent-dashboard button[role="switch"][aria-label="Show idle agents"]'
)
if (!idleSwitch) {
throw new Error('Idle-agent visibility switch was not rendered')
}
await act(async () => {
idleSwitch.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(updateSettings).toHaveBeenCalledWith({ experimentalAgentDashboardShowIdle: true })
root.unmount()
})
it('renders per-workspace environments as an off-by-default experimental subsection', () => {
const settings = getDefaultSettings('/tmp')
const markup = renderToStaticMarkup(
@@ -20,7 +20,7 @@ const mocks = vi.hoisted(() => ({
refreshPreflightStatus: vi.fn(),
checkLinearConnection: vi.fn(),
hasPairedMobileDevice: false,
agentBucketCounts: { attention: 0, working: 0, idle: 0 },
agentBucketCounts: { attention: 0, working: 0, done: 0, idle: 0 },
dismissMobileOnboardingBadge: vi.fn(),
setSetupGuideSidebarDismissed: vi.fn()
}))
@@ -207,7 +207,7 @@ describe('SidebarNav', () => {
vi.clearAllMocks()
await i18n.changeLanguage('en')
mocks.hasPairedMobileDevice = false
mocks.agentBucketCounts = { attention: 0, working: 0, idle: 0 }
mocks.agentBucketCounts = { attention: 0, working: 0, done: 0, idle: 0 }
setSidebarState()
})
@@ -258,22 +258,26 @@ describe('SidebarNav', () => {
})
it('uses a question glyph only for the Needs You count', async () => {
mocks.agentBucketCounts = { attention: 2, working: 3, idle: 4 }
mocks.agentBucketCounts = { attention: 2, working: 3, done: 1, idle: 4 }
setSidebarState({
settings: {
...getDefaultSettings('/tmp'),
experimentalAgentDashboardPopout: true
experimentalAgentDashboardPopout: true,
experimentalAgentDashboardShowIdle: true
}
})
const container = await renderSidebarNav()
const attention = container.querySelector('[aria-label="Needs You: 2"]')
const working = container.querySelector('[aria-label="Working: 3"]')
const done = container.querySelector('[aria-label="Done: 1"]')
const idle = container.querySelector('[aria-label="Idle: 4"]')
expect(attention?.querySelector('.lucide-message-circle-question-mark')).not.toBeNull()
expect(working?.querySelector('.rounded-full')).not.toBeNull()
expect(done?.querySelector('.rounded-full')).not.toBeNull()
expect(idle?.querySelector('.rounded-full')).not.toBeNull()
expect(working?.querySelector('svg')).toBeNull()
expect(done?.querySelector('svg')).toBeNull()
expect(idle?.querySelector('svg')).toBeNull()
})
@@ -60,8 +60,9 @@ export function shouldShowAutomationsButton(
return settings?.showAutomationsButton !== false
}
const DASHBOARD_BUCKET_DOT_CLASS: Record<'working' | 'idle', string> = {
const DASHBOARD_BUCKET_DOT_CLASS: Record<'working' | 'done' | 'idle', string> = {
working: 'bg-yellow-500',
done: 'bg-emerald-500',
idle: 'bg-neutral-500/50'
}
@@ -71,17 +72,23 @@ function dashboardBucketLabel(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')
}
}
function DashboardBucketCounts({
counts
counts,
showIdle
}: {
counts: Record<DashboardBucket, number>
showIdle: boolean
}): React.JSX.Element | null {
const active = DASHBOARD_BUCKET_ORDER.filter((bucket) => counts[bucket] > 0)
const active = DASHBOARD_BUCKET_ORDER.filter(
(bucket) => counts[bucket] > 0 && (bucket !== 'idle' || showIdle)
)
if (active.length === 0) {
return null
}
@@ -109,6 +116,7 @@ function DashboardBucketCounts({
// agent-status churn only updates this opt-in row, not the full navigation.
function AgentDashboardSidebarEntry(): React.JSX.Element {
const dashboardBucketCounts = useAgentBucketCounts()
const showIdle = useAppStore((s) => s.settings?.experimentalAgentDashboardShowIdle === true)
const openAsPopout = useAppStore((s) => isAgentDashboardPopoutMode(s.settings))
const drawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen)
const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen)
@@ -135,7 +143,7 @@ function AgentDashboardSidebarEntry(): React.JSX.Element {
strokeWidth={1.75}
/>
<span className="flex-1">{translate('dashboard.sidebar.label', 'Agent Dashboard')}</span>
<DashboardBucketCounts counts={dashboardBucketCounts} />
<DashboardBucketCounts counts={dashboardBucketCounts} showIdle={showIdle} />
</button>
)
}
+42 -5
View File
@@ -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": {
+42 -5
View File
@@ -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": {
+42 -5
View File
@@ -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": {
+42 -5
View File
@@ -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": {
+42 -5
View File
@@ -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": {
+1
View File
@@ -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', () => {})
+1
View File
@@ -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,
+43 -9
View File
@@ -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: {}
}
+2
View File
@@ -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. */