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