Remove agent map view from dashboard (#15853)

* Remove agent map view from dashboard

Removes the view toggle and simplifies the dashboard to show only the kanban board layout.

* Assert boardProps is initialized on drawer open
This commit is contained in:
Jinjing
2026-08-21 21:43:54 -07:00
committed by GitHub
parent a157f4cfec
commit 42a42281bd
7 changed files with 55 additions and 500 deletions
@@ -3,20 +3,15 @@
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 { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import type {
DashboardCard,
DashboardFilterOptions,
DashboardSnapshot,
DashboardWorkspace
DashboardSnapshot
} from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { i18n } from '@/i18n/i18n'
import { AgentKanbanBoard } from './AgentKanbanBoard'
const MAP_LOAD_TIMEOUT = { timeout: 5_000 }
// Stub the card and dialog so the board test stays free of xterm / Radix
// machinery while still exercising the board-owned dialog wiring.
vi.mock('./AgentKanbanCard', () => ({
@@ -59,17 +54,6 @@ vi.mock('./AgentTerminalDialog', () => ({
>
<button data-testid="terminal-dialog-close" onClick={() => onOpenChange(false)} />
</div>
),
AgentTerminalPanel: ({
card,
onOpenChange
}: {
card: DashboardCard | null
onOpenChange: (open: boolean) => void
}) => (
<div data-testid="terminal-panel" data-pty-id={card?.ptyId ?? undefined}>
<button data-testid="terminal-panel-close" onClick={() => onOpenChange(false)} />
</div>
)
}))
@@ -95,26 +79,12 @@ function card(overrides: Partial<DashboardCard>): DashboardCard {
}
}
function workspace(overrides: Partial<DashboardWorkspace> = {}): DashboardWorkspace {
return {
repoId: 'r1',
worktreeId: 'w1',
repoName: 'Repo',
worktreeName: 'wt',
hostKind: 'local',
executionHostId: 'local',
workspaceKind: 'worktree',
...overrides
}
}
function renderBoard(
cards: DashboardCard[],
options: {
showIdle?: boolean
repoIconsByRepoId?: Record<string, RepoIcon | null>
filterOptions?: DashboardFilterOptions
workspaces?: DashboardWorkspace[]
} = {}
): void {
const snapshot: DashboardSnapshot = { generatedAt: 1, cards, ...options }
@@ -148,241 +118,21 @@ describe('AgentKanbanBoard', () => {
expect(headers.map((h) => h.textContent)).toEqual(['Needs You', 'Working', 'Done'])
})
it('loads the map as a recoverable dynamic chunk', () => {
const source = readFileSync(
resolve(process.cwd(), 'src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx'),
'utf8'
)
expect(source).toContain("import { lazyWithRetry } from '@/lib/lazy-with-retry'")
expect(source).toMatch(/import\('\.\/AgentDashboardMapView'\)/)
expect(source).not.toMatch(/from\s+['"]\.\/(?:AgentMap|useAgentMap|agent-map-)/)
})
it('keeps the dashboard and map available as separate views', async () => {
it('hides the agent map from dashboard chrome', () => {
renderBoard([])
fireEvent.click(screen.getByRole('button', { name: 'Agent Map' }))
expect(
await screen.findByRole('button', { name: /^Filter/ }, MAP_LOAD_TIMEOUT)
).toBeInTheDocument()
expect(screen.queryByText('Live containment map')).not.toBeInTheDocument()
// The map has no rail of its own; its filters live in the shared toolbar.
expect(screen.queryByRole('complementary', { name: 'Map filters' })).not.toBeInTheDocument()
expect(screen.queryByText('Agent states')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Dashboard' }))
expect(screen.queryByRole('button', { name: 'Agent Map' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Dashboard' })).not.toBeInTheDocument()
expect(screen.queryByRole('group', { name: 'Dashboard view' })).not.toBeInTheDocument()
expect(screen.getByText('Needs You')).toBeInTheDocument()
})
it('filters the map from the shared toolbar filter, not a rail', async () => {
renderBoard([
card({ paneKey: 'busy', worktreeName: 'busy-wt', worktreeId: 'w-busy' }),
card({
paneKey: 'finished',
worktreeName: 'done-wt',
worktreeId: 'w-done',
bucket: 'done',
dotState: 'done',
finishedAt: 5,
unseen: true
})
])
fireEvent.click(screen.getByRole('button', { name: 'Agent Map' }))
fireEvent.click(await screen.findByRole('button', { name: /^Filter/ }, MAP_LOAD_TIMEOUT))
// The count lives in the panel header now, beside the sections it explains.
const shown = await screen.findByText('of 2 agents shown')
expect(shown.parentElement).toHaveTextContent('2 of 2 agents shown')
fireEvent.click(screen.getByRole('button', { name: /Agent states/ }))
fireEvent.click(await screen.findByRole('checkbox', { name: /Working/ }))
expect(shown.parentElement).toHaveTextContent('1 of 2 agents shown')
// A muted state counts toward the Filter badge like any other filter.
expect(screen.getByRole('button', { name: /^Filter/ })).toHaveAccessibleName(/1/)
})
it('offers agent states only on the map, where no column separates them', async () => {
it('offers project filters without agent-state map filters', async () => {
renderBoard([card({ paneKey: 'busy' })])
fireEvent.pointerDown(screen.getByRole('button', { name: /^Filter/ }))
expect(await screen.findByText('Project')).toBeInTheDocument()
expect(screen.queryByText('Agent states')).not.toBeInTheDocument()
fireEvent.keyDown(document.body, { key: 'Escape' })
fireEvent.click(screen.getByRole('button', { name: 'Agent Map' }))
fireEvent.click(await screen.findByRole('button', { name: /^Filter/ }, MAP_LOAD_TIMEOUT))
expect(await screen.findByText('Agent states')).toBeInTheDocument()
})
it('toggles workspaces without agents from the shared map filter', async () => {
renderBoard([card({ paneKey: 'busy' })], {
workspaces: [workspace(), workspace({ worktreeId: 'empty', worktreeName: 'Empty child' })]
})
fireEvent.click(screen.getByRole('button', { name: 'Agent Map' }))
expect(
screen.queryByRole('button', { name: 'Open Empty child worktree details' })
).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /^Filter/ }))
fireEvent.click(await screen.findByRole('button', { name: /^Workspace/ }))
const workspaceToggle = await screen.findByRole('checkbox', {
name: /Workspaces without agents/
})
fireEvent.click(workspaceToggle)
expect(workspaceToggle).toHaveAttribute('aria-checked', 'true')
fireEvent.keyDown(document.body, { key: 'Escape' })
expect(
await screen.findByRole(
'button',
{ name: 'Open Empty child worktree details' },
MAP_LOAD_TIMEOUT
)
).toBeInTheDocument()
expect(screen.getByText('Workspaces without agents')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
expect(
screen.queryByRole('button', { name: 'Open Empty child worktree details' })
).not.toBeInTheDocument()
})
it('counts hidden orchestration links and restores them from the active chip', async () => {
renderBoard([
card({ paneKey: 'parent', worktreeId: 'parent-worktree' }),
card({ paneKey: 'child', worktreeId: 'child-worktree', parentPaneKey: 'parent' })
])
fireEvent.click(screen.getByRole('button', { name: 'Agent Map' }))
fireEvent.click(await screen.findByRole('button', { name: /^Filter/ }, MAP_LOAD_TIMEOUT))
fireEvent.click(screen.getByRole('button', { name: /^Workspace/ }))
const linksToggle = screen.getByRole('checkbox', { name: /Orchestration links/ })
fireEvent.click(linksToggle)
expect(linksToggle).toHaveAttribute('aria-checked', 'false')
expect(screen.getByRole('button', { name: /^Filter/ })).toHaveAccessibleName(/1/)
fireEvent.keyDown(document.body, { key: 'Escape' })
expect(screen.getByText('Orchestration links hidden')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Remove Orchestration links hidden' }))
expect(screen.queryByText('Orchestration links hidden')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /^Filter/ })).not.toHaveAccessibleName(/1/)
})
it('shows agentless workspaces across hosts without a host filter', async () => {
renderBoard([card({ paneKey: 'busy' })], {
workspaces: [
workspace(),
workspace({ worktreeId: 'local-empty', worktreeName: 'Local empty' }),
workspace({
worktreeId: 'ssh-empty',
worktreeName: 'SSH empty',
hostKind: 'ssh',
executionHostId: 'ssh:test'
})
]
})
fireEvent.click(screen.getByRole('button', { name: 'Agent Map' }))
fireEvent.click(await screen.findByRole('button', { name: /^Filter/ }, MAP_LOAD_TIMEOUT))
fireEvent.click(screen.getByRole('button', { name: /^Workspace/ }))
fireEvent.click(screen.getByRole('checkbox', { name: /Workspaces without agents/ }))
fireEvent.keyDown(document.body, { key: 'Escape' })
expect(
await screen.findByRole(
'button',
{ name: 'Open Local empty worktree details' },
MAP_LOAD_TIMEOUT
)
).toBeInTheDocument()
expect(
await screen.findByRole(
'button',
{ name: 'Open SSH empty worktree details' },
MAP_LOAD_TIMEOUT
)
).toBeInTheDocument()
})
it('replaces board and content filters when applying a quick view', async () => {
renderBoard(
[
card({
paneKey: 'one',
repoId: 'r1',
repoName: 'One',
workspaceStatusId: 'active',
workspaceStatusLabel: 'Active'
}),
card({ paneKey: 'two', repoId: 'r2', repoName: 'Two', worktreeId: 'w2' })
],
{
workspaces: [workspace(), workspace({ worktreeId: 'empty', worktreeName: 'Empty child' })]
}
)
fireEvent.click(screen.getByRole('button', { name: 'Agent Map' }))
fireEvent.click(await screen.findByRole('button', { name: /^Filter/ }, MAP_LOAD_TIMEOUT))
fireEvent.click(screen.getByRole('button', { name: /^Project/ }))
fireEvent.click(screen.getByRole('checkbox', { name: /One/ }))
fireEvent.click(screen.getByRole('button', { name: /^Workspace/ }))
fireEvent.click(screen.getByRole('checkbox', { name: /Active/ }))
fireEvent.click(screen.getByRole('checkbox', { name: /No review/ }))
fireEvent.click(screen.getByRole('checkbox', { name: /Workspaces without agents/ }))
fireEvent.click(screen.getByRole('button', { name: 'Everything' }))
expect(screen.queryByText('One', { selector: 'span.rounded-full' })).not.toBeInTheDocument()
expect(screen.queryByText('Active', { selector: 'span.rounded-full' })).not.toBeInTheDocument()
expect(screen.queryByText('Review: No review')).not.toBeInTheDocument()
expect(screen.getByText('of 2 agents shown').parentElement).toHaveTextContent(
'2 of 2 agents shown'
)
fireEvent.keyDown(document.body, { key: 'Escape' })
expect(
screen.queryByRole('button', { name: 'Open Empty child worktree details' })
).not.toBeInTheDocument()
})
it('keeps the selected map visible beside its terminal panel', async () => {
const agent = card({ paneKey: 'map-agent', conversationName: 'Map agent' })
render(<AgentKanbanBoard snapshot={{ generatedAt: 1, cards: [agent] }} initialView="map" />)
fireEvent.click(await screen.findByRole('button', { name: /Map agent/ }, MAP_LOAD_TIMEOUT))
expect(screen.getByLabelText('Nested project, workspace, and agent map')).toBeInTheDocument()
const terminalPanel = screen.getByTestId('terminal-panel')
expect(terminalPanel).toHaveAttribute('data-pty-id', 'p1')
expect(terminalPanel.parentElement).toHaveClass('flex-row-reverse')
expect(
screen.getByLabelText('Nested project, workspace, and agent map').closest('section')
).toHaveClass('w-1/2', 'flex-none')
expect(screen.getByRole('button', { name: /Map agent/ })).toHaveClass('is-selected')
expect(screen.getByRole('button', { name: /Map agent/ })).toHaveAttribute(
'aria-pressed',
'true'
)
expect(screen.getByText('200%')).toBeInTheDocument()
expect(screen.queryByText('Map filters')).not.toBeInTheDocument()
expect(screen.queryByTestId('terminal-dialog')).not.toBeInTheDocument()
})
it('closes the adjacent terminal instead of turning it into a board dialog', async () => {
const agent = card({ paneKey: 'map-agent', conversationName: 'Map agent' })
render(<AgentKanbanBoard snapshot={{ generatedAt: 1, cards: [agent] }} initialView="map" />)
fireEvent.click(await screen.findByRole('button', { name: /Map agent/ }, MAP_LOAD_TIMEOUT))
expect(screen.getByTestId('terminal-panel')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Agent Map' }))
expect(screen.getByTestId('terminal-panel')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Dashboard' }))
expect(screen.queryByTestId('terminal-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('terminal-dialog')).toHaveAttribute('data-open', 'false')
})
it('focuses search with Ctrl+K without taking focus from response fields', () => {
@@ -599,52 +349,4 @@ describe('AgentKanbanBoard', () => {
)
expect(ackAgent).toHaveBeenCalledWith('pk-ack')
})
it('keeps an acknowledged result visible as a seen finish in the map without review state', async () => {
const fresh = card({
paneKey: 'fresh-result',
bucket: 'done',
dotState: 'done',
conversationName: 'Fresh result',
finishedAt: 900,
unseen: true
})
const view = render(
<AgentKanbanBoard snapshot={{ generatedAt: 1, cards: [fresh] }} initialView="map" />
)
expect(
await screen.findByRole('button', { name: /Fresh result/ }, MAP_LOAD_TIMEOUT)
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Fresh result/ }))
expect(ackAgent).toHaveBeenCalledWith('fresh-result')
view.rerender(
<AgentKanbanBoard
snapshot={{
generatedAt: 2,
cards: [{ ...fresh, bucket: 'idle', unseen: false }]
}}
initialView="map"
/>
)
expect(screen.getByRole('button', { name: /Fresh result/ })).toHaveClass(
'fleet-status-done-seen'
)
expect(screen.getByTestId('terminal-panel')).toBeInTheDocument()
view.unmount()
render(
<AgentKanbanBoard
snapshot={{
generatedAt: 3,
cards: [{ ...fresh, bucket: 'idle', unseen: false }]
}}
initialView="map"
/>
)
expect(screen.getByRole('button', { name: /Fresh result/ })).toHaveClass(
'fleet-status-done-seen'
)
})
})
@@ -1,12 +1,10 @@
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Columns3, Orbit, XIcon } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { XIcon } from 'lucide-react'
import {
DASHBOARD_BUCKET_ORDER,
type DashboardBucket,
type DashboardCard,
type DashboardSleepWorkspaceArgs,
type DashboardSnapshot,
type DashboardSpawnAgentArgs
type DashboardSnapshot
} from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { cn } from '@/lib/utils'
@@ -22,16 +20,6 @@ import {
} from './agent-board-filtering'
import './agent-board-transitions.css'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import { lazyWithRetry } from '@/lib/lazy-with-retry'
export type AgentDashboardView = 'map' | 'board'
const AgentDashboardMapView = lazyWithRetry(
() =>
import('./AgentDashboardMapView').then((module) => ({ default: module.AgentDashboardMapView })),
{ reloadKey: 'agent-dashboard-map-view' }
)
/** Ack an agent in the pop-out window: relayed over IPC to the main renderer.
* ?. shields dialog-opening from dev-HMR preload skew (renderer updates hot,
@@ -47,18 +35,6 @@ function revealAgentViaPopoutRelay(args: AgentRevealArgs): void {
void window.api.dashboard.revealAgent?.(args)
}
/** Start an agent from the pop-out window: the main renderer owns the store and
* the tab path, so the launch is relayed. Same `?.` HMR-skew guard. */
function spawnAgentViaPopoutRelay(args: DashboardSpawnAgentArgs): void {
void window.api.dashboard.spawnAgent?.(args)
}
/** Sleep a workspace from the pop-out window: the main renderer runs the
* teardown, which has to happen where the terminal panes live. */
function sleepWorkspaceViaPopoutRelay(args: DashboardSleepWorkspaceArgs): void {
void window.api.dashboard.sleepWorkspace?.(args)
}
function bucketLabel(bucket: DashboardBucket): string {
switch (bucket) {
case 'attention':
@@ -138,7 +114,6 @@ function KanbanColumn({
type AgentKanbanBoardProps = {
snapshot: DashboardSnapshot
initialView?: AgentDashboardView
/** Sizing for the outermost container. The pop-out fills the window
* (h-screen w-screen); the in-window drawer fills its host (h-full w-full). */
containerClassName?: string
@@ -148,24 +123,12 @@ type AgentKanbanBoardProps = {
/** Focuses the agent's pane. Defaults to the pop-out IPC relay; the in-window
* host activates the worktree/pane locally and closes the overlay. */
onRevealAgent?: (args: AgentRevealArgs) => void
/** Starts a new agent in a workspace. Defaults to the pop-out IPC relay; the
* in-window host launches through its own store. */
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
/** Puts a workspace to sleep. Defaults to the pop-out IPC relay; the in-window
* host already offers the full sidebar menu, so it opts out. */
onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void
/** When provided, renders a close control in the header (in-window mode). The
* pop-out relies on its native window controls, so it omits this. */
onClose?: () => void
/** Header controls rendered before the close button. The in-window host
* passes its settings menu; the pop-out renderer has no store to drive it. */
headerActions?: React.ReactNode
/** Opens the map outside this renderer. The in-window drawer uses this to
* hand map work to the dedicated pop-out window. */
onOpenMap?: () => void
/** The shared sidebar workspace menu is available only in the main renderer. */
workspaceContextMenusEnabled?: boolean
onWorkspaceContextMenuOpenChange?: (open: boolean) => void
}
/** The agent board: status columns fed by a snapshot. Shared by the pop-out
@@ -173,19 +136,12 @@ type AgentKanbanBoardProps = {
* how ack/reveal are routed. */
export function AgentKanbanBoard({
snapshot,
initialView = 'board',
containerClassName = 'h-screen w-screen',
onAckAgent = ackAgentViaPopoutRelay,
onRevealAgent = revealAgentViaPopoutRelay,
onSpawnAgent = spawnAgentViaPopoutRelay,
onSleepWorkspace = sleepWorkspaceViaPopoutRelay,
onClose,
headerActions,
onOpenMap,
workspaceContextMenusEnabled = false,
onWorkspaceContextMenuOpenChange
headerActions
}: AgentKanbanBoardProps): React.JSX.Element {
const [view, setView] = useState(initialView)
const visibleBuckets = useMemo(
() =>
DASHBOARD_BUCKET_ORDER.filter((bucket) => bucket !== 'idle' || snapshot.showIdle === true),
@@ -195,13 +151,12 @@ export function AgentKanbanBoard({
() => snapshot.cards.filter((card) => visibleBuckets.includes(card.bucket)),
[snapshot.cards, visibleBuckets]
)
const availableCards = view === 'map' ? snapshot.cards : visibleCards
const [query, setQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
const [filters, setFilters] = useState<DashboardFilters>(EMPTY_DASHBOARD_FILTERS)
const filteredCards = useMemo(
() => filterDashboardCards(availableCards, query, filters),
[availableCards, filters, query]
() => filterDashboardCards(visibleCards, query, filters),
[visibleCards, filters, query]
)
const grouped = useMemo(() => groupByBucket(filteredCards), [filteredCards])
const hasRelativeTimestamps = useMemo(
@@ -263,20 +218,6 @@ export function AgentKanbanBoard({
setOpenedCard(null)
}
}, [])
const handleViewChange = useCallback(
(nextView: AgentDashboardView) => {
if (nextView === 'map' && onOpenMap) {
onOpenMap()
return
}
if (nextView === view) {
return
}
setOpenedCard(null)
setView(nextView)
},
[onOpenMap, view]
)
// Seen-state is the app-wide ack map (same signal as the sidebar's bold/mute
// rows): opening a dialog acks the agent, and the next snapshot comes back
@@ -310,37 +251,9 @@ export function AgentKanbanBoard({
</h1>
<span className="text-[11px] text-muted-foreground">
{translate('dashboardPopout.total', '{{count}} total', {
count: availableCards.length
count: visibleCards.length
})}
</span>
<div
className="flex items-center gap-0.5 rounded-md border border-border p-0.5"
role="group"
aria-label={translate('dashboardPopout.view.label', 'Dashboard view')}
>
<Button
type="button"
variant="ghost"
size="xs"
aria-pressed={view === 'board'}
className={cn('h-6 gap-1 px-2', view === 'board' && 'bg-accent')}
onClick={() => handleViewChange('board')}
>
<Columns3 className="size-3" />
{translate('dashboardPopout.view.board', 'Dashboard')}
</Button>
<Button
type="button"
variant="ghost"
size="xs"
aria-pressed={view === 'map'}
className={cn('h-6 gap-1 px-2', view === 'map' && 'bg-accent')}
onClick={() => handleViewChange('map')}
>
<Orbit className="size-3" />
{translate('dashboardPopout.view.map', 'Agent Map')}
</Button>
</div>
{headerActions || onClose ? (
<div className="ml-auto flex items-center gap-1">
{headerActions}
@@ -357,63 +270,36 @@ export function AgentKanbanBoard({
</div>
) : null}
</div>
{view !== 'board' ? (
<Suspense fallback={null}>
<AgentDashboardMapView
snapshot={snapshot}
cards={filteredCards}
query={query}
onQueryChange={setQuery}
filters={filters}
onFiltersChange={setFilters}
searchInputRef={searchInputRef}
now={now}
dialogCard={dialogCard}
onDialogOpenChange={handleDialogOpenChange}
onRevealAgent={onRevealAgent}
onOpenTerminal={handleOpenTerminal}
onSpawnAgent={onSpawnAgent}
onSleepWorkspace={onSleepWorkspace}
workspaceContextMenusEnabled={workspaceContextMenusEnabled}
onWorkspaceContextMenuOpenChange={onWorkspaceContextMenuOpenChange}
/>
</Suspense>
) : (
<>
<AgentDashboardToolbar
cards={visibleCards}
filterOptions={snapshot.filterOptions}
filteredCount={filteredCards.length}
query={query}
onQueryChange={setQuery}
filters={filters}
onFiltersChange={setFilters}
searchInputRef={searchInputRef}
/>
<div className="scrollbar-sleek flex min-h-0 flex-1 overflow-x-auto p-3">
{/* Auto margins center the capped board and collapse during horizontal overflow. */}
<div className="mx-auto flex w-full max-w-[1280px] gap-3">
{visibleBuckets.map((bucket) => (
<KanbanColumn
key={bucket}
bucket={bucket}
cards={grouped[bucket]}
repoIconsByRepoId={snapshot.repoIconsByRepoId}
now={now}
onOpenTerminal={handleOpenTerminal}
/>
))}
</div>
</div>
</>
)}
{view === 'board' ? (
<AgentTerminalDialog
card={dialogCard}
onOpenChange={handleDialogOpenChange}
onReveal={onRevealAgent}
/>
) : null}
<AgentDashboardToolbar
cards={visibleCards}
filterOptions={snapshot.filterOptions}
filteredCount={filteredCards.length}
query={query}
onQueryChange={setQuery}
filters={filters}
onFiltersChange={setFilters}
searchInputRef={searchInputRef}
/>
<div className="scrollbar-sleek flex min-h-0 flex-1 overflow-x-auto p-3">
{/* Auto margins center the capped board and collapse during horizontal overflow. */}
<div className="mx-auto flex w-full max-w-[1280px] gap-3">
{visibleBuckets.map((bucket) => (
<KanbanColumn
key={bucket}
bucket={bucket}
cards={grouped[bucket]}
repoIconsByRepoId={snapshot.repoIconsByRepoId}
now={now}
onOpenTerminal={handleOpenTerminal}
/>
))}
</div>
</div>
<AgentTerminalDialog
card={dialogCard}
onOpenChange={handleDialogOpenChange}
onReveal={onRevealAgent}
/>
</div>
</TooltipProvider>
)
@@ -1,21 +1,11 @@
import { useEffect, useState } from 'react'
import { AgentKanbanBoard, type AgentDashboardView } from './AgentKanbanBoard'
import { AgentKanbanBoard } from './AgentKanbanBoard'
import { useDashboardSnapshot } from './useDashboardSnapshot'
type DashboardPopoutRootProps = {
/** The layout requested via popout.html?view=<name>. */
view: string | null
}
/**
* Root of the pop-out dashboard window. Subscribes to the live snapshot relayed
* from the main window and renders the requested layout.
* from the main window and renders the agent board.
*/
export function DashboardPopoutRoot(_props: DashboardPopoutRootProps): React.JSX.Element {
export function DashboardPopoutRoot(): React.JSX.Element {
const snapshot = useDashboardSnapshot()
const [view, setView] = useState<AgentDashboardView>(() =>
_props.view === 'map' || _props.view === 'rings' ? 'map' : 'board'
)
useEffect(() => window.api.dashboard.onViewRequested(setView), [])
return <AgentKanbanBoard key={view} snapshot={snapshot} initialView={view} />
return <AgentKanbanBoard snapshot={snapshot} />
}
@@ -85,23 +85,14 @@ describe('AgentDashboardDrawer', () => {
expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false)
})
it('hands map rendering to the dedicated popout', () => {
const openPopout = vi.mocked(window.api.dashboard.openPopout)
it('does not hand the drawer over to an agent map popout', () => {
render(<AgentDashboardDrawer statusBarVisible />)
expect(mocks.boardProps).toBeNull()
act(() => useAppStore.setState({ agentDashboardDrawerOpen: true }))
expect(mocks.boardProps?.initialView).toBe('board')
expect(mocks.boardProps?.workspaceContextMenusEnabled).toBeUndefined()
const onOpenMap = mocks.boardProps?.onOpenMap
expect(onOpenMap).toBeTypeOf('function')
act(() => {
;(onOpenMap as () => void)()
})
expect(openPopout).toHaveBeenCalledWith('map')
expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false)
expect(mocks.boardProps).not.toBeNull()
expect(mocks.boardProps?.onOpenMap).toBeUndefined()
expect(mocks.boardProps?.initialView).toBeUndefined()
})
it('reveals a colliding worktree on the card execution host', () => {
@@ -13,7 +13,6 @@ import {
WORKSPACE_TOP_CHROME_HEIGHT
} from '../sidebar/workspace-chrome-metrics'
import { AgentDashboardSettingsMenu } from './AgentDashboardSettingsMenu'
import { launchDashboardAgent } from './launch-dashboard-agent'
import { useLiveDashboardSnapshot } from './useLiveDashboardSnapshot'
import { translate } from '@/i18n/i18n'
@@ -62,23 +61,15 @@ function AgentDashboardDrawerBody({
void window.api.dashboard.openPopout?.()
}, [onClose])
const handleOpenMap = useCallback(() => {
onClose()
void window.api.dashboard.openPopout?.('map')
}, [onClose])
return (
<AgentKanbanBoard
snapshot={snapshot}
initialView="board"
// Why: bg-transparent lets the sheet's worktree-sidebar surface through
// so the board reads as the same companion panel as the workspace board.
containerClassName="h-full w-full bg-transparent"
onAckAgent={handleAckAgent}
onRevealAgent={handleRevealAgent}
onSpawnAgent={launchDashboardAgent}
onClose={onClose}
onOpenMap={handleOpenMap}
headerActions={
<AgentDashboardSettingsMenu
onSwitchToPopout={handleSwitchToPopout}
@@ -28,11 +28,10 @@ describe('agent dashboard performance isolation', () => {
const drawer = source('components/dashboard/AgentDashboardDrawer.tsx')
const toolbar = source('components/dashboard-popout/AgentDashboardToolbar.tsx')
expect(board).toContain("import('./AgentDashboardMapView')")
expect(board).not.toContain("import('./AgentDashboardMapView')")
expect(board).not.toMatch(/from ['"].\/(?:AgentMap|useAgentMap|agent-map-)/)
expect(toolbar).not.toMatch(/from ['"].\/(?:AgentMap|useAgentMap|agent-map-)/)
expect(drawer).toContain('initialView="board"')
expect(drawer).toContain("openPopout?.('map')")
expect(drawer).toContain('onOpenMap={handleOpenMap}')
expect(drawer).not.toContain("openPopout?.('map')")
expect(drawer).not.toContain('onOpenMap')
})
})
+1 -5
View File
@@ -50,10 +50,6 @@ if (!rootElement) {
throw new Error('Pop-out root element not found.')
}
// The main process loads popout.html with ?view=<name> so a single entry can
// host different dashboard layouts (kanban, etc.).
const requestedView = new URLSearchParams(window.location.search).get('view')
function PopoutSettingsSync(): null {
const settings = useAppStore((state) => state.settings)
@@ -109,7 +105,7 @@ function PopoutRoot(): React.JSX.Element {
'The dashboard could not finish rendering. Retry to remount it, or reopen it.'
)}
>
<DashboardPopoutRoot view={requestedView} />
<DashboardPopoutRoot />
</RecoverableRenderErrorBoundary>
)
}