mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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', () => {})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user