mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(agent-map): isolate map work from main renderer
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { DASHBOARD_MAX_LABEL_LENGTH } from '../../shared/dashboard-snapshot'
|
||||
|
||||
const MAX_DASHBOARD_FILTER_OPTIONS = 500
|
||||
const MAX_ID_LENGTH = 4_096
|
||||
|
||||
function isBoundedString(value: unknown, maxLength: number, allowEmpty = false): value is string {
|
||||
return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0)
|
||||
}
|
||||
|
||||
function isOptionalBoundedString(value: unknown, maxLength: number): boolean {
|
||||
return value === undefined || isBoundedString(value, maxLength, true)
|
||||
}
|
||||
|
||||
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, DASHBOARD_MAX_LABEL_LENGTH, true) &&
|
||||
isOptionalBoundedString(option.color, MAX_ID_LENGTH)
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export 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)
|
||||
)
|
||||
}
|
||||
@@ -59,6 +59,23 @@ const SNAPSHOT = {
|
||||
askSummary: '{"question":"Proceed?"}'
|
||||
}
|
||||
],
|
||||
workspaces: [
|
||||
{
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'worktree-1',
|
||||
repoName: 'Orca',
|
||||
worktreeName: 'Dashboard',
|
||||
parentWorktreeId: 'parent-worktree-1',
|
||||
hostKind: 'ssh',
|
||||
executionHostId: 'ssh:build-box',
|
||||
workspaceKind: 'worktree',
|
||||
workspaceStatusId: 'in-review',
|
||||
workspaceStatusLabel: 'In review',
|
||||
workspaceStatusColor: 'emerald',
|
||||
hasReview: true,
|
||||
review: { number: 11012, state: 'open' }
|
||||
}
|
||||
],
|
||||
showIdle: false,
|
||||
filterOptions: {
|
||||
projects: [{ id: 'repo-1', label: 'Orca' }],
|
||||
@@ -204,6 +221,23 @@ describe('dashboard payload validation', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('validates bounded map workspace metadata independently of cards', () => {
|
||||
expect(isDashboardSnapshot({ ...SNAPSHOT, cards: [] })).toBe(true)
|
||||
expect(isDashboardSnapshot({ ...SNAPSHOT, workspaces: undefined })).toBe(true)
|
||||
expect(
|
||||
isDashboardSnapshot({
|
||||
...SNAPSHOT,
|
||||
workspaces: [{ ...SNAPSHOT.workspaces[0], executionHostId: 'build-box' }]
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isDashboardSnapshot({
|
||||
...SNAPSHOT,
|
||||
workspaces: [{ ...SNAPSHOT.workspaces[0], worktreeName: 'x'.repeat(1_025) }]
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('validates bounded launch choices and spawn requests', () => {
|
||||
expect(
|
||||
isDashboardSnapshot({
|
||||
@@ -303,6 +337,19 @@ describe('dashboard payload validation', () => {
|
||||
expect(admitted?.snapshot.cards.map((card) => card.paneKey)).toEqual(['tab-1:leaf-1'])
|
||||
})
|
||||
|
||||
it('drops only malformed optional workspace metadata', () => {
|
||||
const admitted = admitDashboardSnapshot({
|
||||
...SNAPSHOT,
|
||||
workspaces: [
|
||||
SNAPSHOT.workspaces[0],
|
||||
{ ...SNAPSHOT.workspaces[0], worktreeId: '', worktreeName: 'Invalid' }
|
||||
]
|
||||
})
|
||||
|
||||
expect(admitted?.droppedCardCount).toBe(0)
|
||||
expect(admitted?.snapshot.workspaces).toEqual([SNAPSHOT.workspaces[0]])
|
||||
})
|
||||
|
||||
it('reports nothing dropped for a fully valid snapshot', () => {
|
||||
const admitted = admitDashboardSnapshot(SNAPSHOT)
|
||||
|
||||
|
||||
@@ -14,12 +14,16 @@ import {
|
||||
AGENT_TYPE_MAX_LENGTH
|
||||
} from '../../shared/agent-status-types'
|
||||
import { isDashboardLaunchOptions } from './dashboard-agent-launch-validation'
|
||||
import {
|
||||
admitDashboardWorkspaces,
|
||||
isDashboardWorkspaceList
|
||||
} from './dashboard-workspace-payload-validation'
|
||||
import { isDashboardFilterOptions } from './dashboard-filter-payload-validation'
|
||||
export { isDashboardSpawnAgentArgs } from './dashboard-agent-launch-validation'
|
||||
|
||||
const MAX_DASHBOARD_CARDS = 1_000
|
||||
const MAX_DASHBOARD_SUBAGENTS = 100
|
||||
const MAX_DASHBOARD_REPO_ICONS = 500
|
||||
const MAX_DASHBOARD_FILTER_OPTIONS = 500
|
||||
// Why: sanitizing an image icon base64-decodes the whole data URI to read a
|
||||
// 24-byte header, and the renderer republishes the same icons every 250 ms.
|
||||
const MAX_CACHED_ICON_SRC_BYTES = 8 * 1024 * 1024
|
||||
@@ -101,6 +105,7 @@ export function isDashboardSnapshot(value: unknown): value is DashboardSnapshot
|
||||
Array.isArray(snapshot.cards) &&
|
||||
snapshot.cards.length <= MAX_DASHBOARD_CARDS &&
|
||||
snapshot.cards.every(isDashboardCard) &&
|
||||
isDashboardWorkspaceList(snapshot.workspaces) &&
|
||||
(snapshot.showIdle === undefined || typeof snapshot.showIdle === 'boolean') &&
|
||||
isDashboardFilterOptions(snapshot.filterOptions) &&
|
||||
isDashboardLaunchOptions(snapshot.launchableAgentsByWorktreeId) &&
|
||||
@@ -126,10 +131,12 @@ export function admitDashboardSnapshot(value: unknown): DashboardSnapshotAdmissi
|
||||
return null
|
||||
}
|
||||
const snapshot = value as Record<string, unknown>
|
||||
const workspaces = admitDashboardWorkspaces(snapshot.workspaces)
|
||||
if (
|
||||
!isFiniteNumber(snapshot.generatedAt) ||
|
||||
!Array.isArray(snapshot.cards) ||
|
||||
snapshot.cards.length > MAX_DASHBOARD_CARDS ||
|
||||
workspaces === null ||
|
||||
(snapshot.showIdle !== undefined && typeof snapshot.showIdle !== 'boolean') ||
|
||||
!isDashboardFilterOptions(snapshot.filterOptions) ||
|
||||
!isDashboardLaunchOptions(snapshot.launchableAgentsByWorktreeId) ||
|
||||
@@ -139,43 +146,15 @@ export function admitDashboardSnapshot(value: unknown): DashboardSnapshotAdmissi
|
||||
}
|
||||
const cards = snapshot.cards.filter(isDashboardCard)
|
||||
return {
|
||||
snapshot: { ...(snapshot as unknown as DashboardSnapshot), cards },
|
||||
snapshot: {
|
||||
...(snapshot as unknown as DashboardSnapshot),
|
||||
cards,
|
||||
...(workspaces ? { workspaces } : {})
|
||||
},
|
||||
droppedCardCount: snapshot.cards.length - cards.length
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -127,6 +127,15 @@ describe('registerDashboardPopoutHandlers', () => {
|
||||
expect(createPopoutMock).toHaveBeenCalledWith(store, undefined, {
|
||||
getKeybindings: expect.any(Function)
|
||||
})
|
||||
|
||||
handlers.get('dashboardPopout:open')!({ sender: mainSender } as never, 'map')
|
||||
expect(createPopoutMock).toHaveBeenLastCalledWith(store, 'map', {
|
||||
getKeybindings: expect.any(Function)
|
||||
})
|
||||
|
||||
createPopoutMock.mockClear()
|
||||
handlers.get('dashboardPopout:open')!({ sender: mainSender } as never, 'invalid')
|
||||
expect(createPopoutMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('auto-closes the popout when the feature is disabled', () => {
|
||||
|
||||
@@ -56,11 +56,14 @@ export function registerDashboardPopoutHandlers(
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('dashboardPopout:open', (event): void => {
|
||||
ipcMain.handle('dashboardPopout:open', (event, view: unknown): void => {
|
||||
if (!isTrustedUIRenderer(event.sender) || !isDashboardEnabled(store)) {
|
||||
return
|
||||
}
|
||||
createOrFocusDashboardPopout(store, undefined, {
|
||||
if (view !== undefined && view !== 'board' && view !== 'map') {
|
||||
return
|
||||
}
|
||||
createOrFocusDashboardPopout(store, view, {
|
||||
getKeybindings: () => keybindings?.getOverrides()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
DASHBOARD_MAX_LABEL_LENGTH,
|
||||
DASHBOARD_MAX_MAP_WORKSPACES,
|
||||
type DashboardWorkspace
|
||||
} from '../../shared/dashboard-snapshot'
|
||||
import { normalizeExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
const MAX_ID_LENGTH = 4_096
|
||||
const HOST_KINDS = new Set(['local', 'ssh', 'wsl', 'remote'])
|
||||
const WORKSPACE_KINDS = new Set(['worktree', 'folder'])
|
||||
const REVIEW_STATES = new Set(['open', 'closed', 'merged', 'draft'])
|
||||
|
||||
function isString(value: unknown, maxLength: number, allowEmpty = false): value is string {
|
||||
return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0)
|
||||
}
|
||||
|
||||
function isOptionalString(value: unknown, maxLength: number): boolean {
|
||||
return value === undefined || isString(value, maxLength, true)
|
||||
}
|
||||
|
||||
function isReview(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 (
|
||||
typeof review.number === 'number' &&
|
||||
Number.isFinite(review.number) &&
|
||||
review.number > 0 &&
|
||||
typeof review.state === 'string' &&
|
||||
REVIEW_STATES.has(review.state)
|
||||
)
|
||||
}
|
||||
|
||||
export function isDashboardWorkspace(value: unknown): value is DashboardWorkspace {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
const workspace = value as Record<string, unknown>
|
||||
return (
|
||||
isString(workspace.repoId, MAX_ID_LENGTH) &&
|
||||
isString(workspace.worktreeId, MAX_ID_LENGTH) &&
|
||||
isString(workspace.repoName, DASHBOARD_MAX_LABEL_LENGTH, true) &&
|
||||
isString(workspace.worktreeName, DASHBOARD_MAX_LABEL_LENGTH, true) &&
|
||||
isOptionalString(workspace.parentWorktreeId, MAX_ID_LENGTH) &&
|
||||
typeof workspace.hostKind === 'string' &&
|
||||
HOST_KINDS.has(workspace.hostKind) &&
|
||||
isString(workspace.executionHostId, MAX_ID_LENGTH) &&
|
||||
normalizeExecutionHostId(workspace.executionHostId) !== null &&
|
||||
typeof workspace.workspaceKind === 'string' &&
|
||||
WORKSPACE_KINDS.has(workspace.workspaceKind) &&
|
||||
isOptionalString(workspace.workspaceStatusId, MAX_ID_LENGTH) &&
|
||||
isOptionalString(workspace.workspaceStatusLabel, DASHBOARD_MAX_LABEL_LENGTH) &&
|
||||
isOptionalString(workspace.workspaceStatusColor, MAX_ID_LENGTH) &&
|
||||
(workspace.hasReview === undefined || typeof workspace.hasReview === 'boolean') &&
|
||||
isReview(workspace.review)
|
||||
)
|
||||
}
|
||||
|
||||
export function isDashboardWorkspaceList(value: unknown): boolean {
|
||||
return (
|
||||
value === undefined ||
|
||||
(Array.isArray(value) &&
|
||||
value.length <= DASHBOARD_MAX_MAP_WORKSPACES &&
|
||||
value.every(isDashboardWorkspace))
|
||||
)
|
||||
}
|
||||
|
||||
export function admitDashboardWorkspaces(value: unknown): DashboardWorkspace[] | undefined | null {
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (!Array.isArray(value) || value.length > DASHBOARD_MAX_MAP_WORKSPACES) {
|
||||
return null
|
||||
}
|
||||
return value.filter(isDashboardWorkspace)
|
||||
}
|
||||
@@ -255,6 +255,16 @@ describe('createOrFocusDashboardPopout', () => {
|
||||
expect(instances[0].focus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('switches an existing popout to an explicitly requested view', () => {
|
||||
const store = makeStore()
|
||||
createOrFocusDashboardPopout(store as never)
|
||||
const win = instances[0]
|
||||
|
||||
createOrFocusDashboardPopout(store as never, 'map')
|
||||
|
||||
expect(win.webContents.send).toHaveBeenCalledWith('dashboard:viewRequested', 'map')
|
||||
})
|
||||
|
||||
it('trusts only the live popout webContents', () => {
|
||||
const win = createOrFocusDashboardPopout(makeStore() as never) as unknown as FakeWindow
|
||||
expect(isDashboardPopoutRenderer(win.webContents as never)).toBe(true)
|
||||
|
||||
@@ -138,7 +138,7 @@ function resolveRestoredBounds(store: Store | null): {
|
||||
*/
|
||||
export function createOrFocusDashboardPopout(
|
||||
store: Store | null,
|
||||
view: string = DEFAULT_VIEW,
|
||||
view?: string,
|
||||
options: { getKeybindings?: () => KeybindingOverrides | undefined } = {}
|
||||
): BrowserWindow {
|
||||
if (dashboardPopoutWindow && !dashboardPopoutWindow.isDestroyed()) {
|
||||
@@ -146,9 +146,14 @@ export function createOrFocusDashboardPopout(
|
||||
dashboardPopoutWindow.restore()
|
||||
}
|
||||
dashboardPopoutWindow.focus()
|
||||
if (view) {
|
||||
dashboardPopoutWindow.webContents.send('dashboard:viewRequested', view)
|
||||
}
|
||||
return dashboardPopoutWindow
|
||||
}
|
||||
|
||||
const initialView = view ?? DEFAULT_VIEW
|
||||
|
||||
const savedBounds = resolveRestoredBounds(store)
|
||||
|
||||
const window = new BrowserWindow({
|
||||
@@ -283,7 +288,7 @@ export function createOrFocusDashboardPopout(
|
||||
broadcastPopoutOpenChanged(false)
|
||||
})
|
||||
|
||||
loadDashboardPopout(window, view)
|
||||
loadDashboardPopout(window, initialView)
|
||||
return window
|
||||
}
|
||||
|
||||
|
||||
@@ -2497,7 +2497,7 @@ export type PreloadApi = {
|
||||
) => Promise<OnboardingState>
|
||||
}
|
||||
dashboard: {
|
||||
openPopout: () => Promise<void>
|
||||
openPopout: (view?: 'board' | 'map') => Promise<void>
|
||||
publishSnapshot: (snapshot: DashboardSnapshot) => Promise<void>
|
||||
getPopoutOpen: () => Promise<boolean>
|
||||
onPopoutOpenChanged: (callback: (open: boolean) => void) => () => void
|
||||
@@ -2508,6 +2508,7 @@ export type PreloadApi = {
|
||||
onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void) => () => void
|
||||
requestSnapshot: () => Promise<void>
|
||||
onSnapshot: (callback: (snapshot: DashboardSnapshot) => void) => () => void
|
||||
onViewRequested: (callback: (view: 'board' | 'map') => void) => () => void
|
||||
revealAgent: (args: DashboardRevealAgentArgs) => Promise<void>
|
||||
ackAgent: (paneKey: string) => Promise<void>
|
||||
spawnAgent: (args: DashboardSpawnAgentArgs) => Promise<void>
|
||||
|
||||
@@ -2282,7 +2282,8 @@ const api = {
|
||||
|
||||
dashboard: {
|
||||
// Open the pop-out dashboard window, or focus it if already open.
|
||||
openPopout: (): Promise<void> => ipcRenderer.invoke('dashboardPopout:open'),
|
||||
openPopout: (view?: 'board' | 'map'): Promise<void> =>
|
||||
ipcRenderer.invoke('dashboardPopout:open', view),
|
||||
|
||||
// ── Producer side (main window) ──────────────────────────────────────
|
||||
publishSnapshot: (snapshot: DashboardSnapshot): Promise<void> =>
|
||||
@@ -2333,6 +2334,12 @@ const api = {
|
||||
ipcRenderer.on('dashboard:snapshot', listener)
|
||||
return () => ipcRenderer.removeListener('dashboard:snapshot', listener)
|
||||
},
|
||||
onViewRequested: (callback: (view: 'board' | 'map') => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, view: 'board' | 'map'): void =>
|
||||
callback(view)
|
||||
ipcRenderer.on('dashboard:viewRequested', listener)
|
||||
return () => ipcRenderer.removeListener('dashboard:viewRequested', listener)
|
||||
},
|
||||
revealAgent: (args: DashboardRevealAgentArgs): Promise<void> =>
|
||||
ipcRenderer.invoke('dashboardPopout:revealAgent', args),
|
||||
ackAgent: (paneKey: string): Promise<void> =>
|
||||
|
||||
@@ -92,7 +92,6 @@ import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher'
|
||||
import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling'
|
||||
import { useEditorExternalWatch } from './hooks/useEditorExternalWatch'
|
||||
import { useAutoAckViewedAgent } from './hooks/useAutoAckViewedAgent'
|
||||
import { useDashboardPopoutBridge } from './components/dashboard/useDashboardPopoutBridge'
|
||||
import { useUnreadDockBadge } from './hooks/useUnreadDockBadge'
|
||||
import {
|
||||
resolvePrimarySelectionMiddleClickPaste,
|
||||
@@ -389,6 +388,7 @@ const FloatingTerminalPanel = lazy(() =>
|
||||
)
|
||||
// Why: lazy so the WebP asset + overlay module aren't fetched unless the experimental flag is on.
|
||||
const PetOverlay = lazy(() => import('./components/pet/PetOverlay'))
|
||||
const DashboardPopoutBridge = lazy(() => import('./components/dashboard/DashboardPopoutBridge'))
|
||||
// Why: lazy so onboarding's step modules + assets aren't fetched for users past first-launch.
|
||||
const OnboardingFlow = lazy(() => import('./components/onboarding/OnboardingFlow'))
|
||||
|
||||
@@ -757,8 +757,6 @@ function App(): React.JSX.Element {
|
||||
useEditorExternalWatch()
|
||||
useGlobalFileDrop()
|
||||
useAutoAckViewedAgent()
|
||||
useDashboardPopoutBridge(settings?.experimentalAgentDashboardPopout === true)
|
||||
|
||||
useEffect(() => {
|
||||
return onOnboardingReopened(setOnboarding)
|
||||
}, [])
|
||||
@@ -2249,6 +2247,11 @@ function App(): React.JSX.Element {
|
||||
<MacosTccPromptNoticeHost />
|
||||
{/* Why: leaf-mounted retention sync keeps agent-status subscriptions out of the App render tree. */}
|
||||
<RetainedAgentsSyncGate />
|
||||
{settings?.experimentalAgentDashboardPopout === true ? (
|
||||
<Suspense fallback={null}>
|
||||
<DashboardPopoutBridge />
|
||||
</Suspense>
|
||||
) : null}
|
||||
<AgentHibernationGate />
|
||||
{/* Why: workspace activation is a hot path; activeWorktreeId in reset keys would remount whole surfaces during wake. */}
|
||||
<RecoverableRenderErrorBoundary
|
||||
|
||||
@@ -34,18 +34,22 @@ export function AgentDashboardFilterChips({
|
||||
projects,
|
||||
statuses,
|
||||
reviewLabel,
|
||||
showAgentlessWorkspaces,
|
||||
onProjectToggle,
|
||||
onStatusToggle,
|
||||
onReviewToggle,
|
||||
onAgentlessWorkspacesToggle,
|
||||
onClear
|
||||
}: {
|
||||
filters: DashboardFilters
|
||||
projects: FilterLabel[]
|
||||
statuses: FilterLabel[]
|
||||
reviewLabel: (id: DashboardReviewFilter) => string
|
||||
showAgentlessWorkspaces: boolean
|
||||
onProjectToggle: (id: string) => void
|
||||
onStatusToggle: (id: string) => void
|
||||
onReviewToggle: (id: DashboardReviewFilter) => void
|
||||
onAgentlessWorkspacesToggle: () => void
|
||||
onClear: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
@@ -74,6 +78,15 @@ export function AgentDashboardFilterChips({
|
||||
onRemove={() => onReviewToggle(id)}
|
||||
/>
|
||||
))}
|
||||
{showAgentlessWorkspaces ? (
|
||||
<ActiveChip
|
||||
label={translate(
|
||||
'dashboardPopout.map.filters.agentlessWorkspaces',
|
||||
'Workspaces without agents'
|
||||
)}
|
||||
onRemove={onAgentlessWorkspacesToggle}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
variant="link"
|
||||
size="xs"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Suspense, useMemo, useState, type RefObject } from 'react'
|
||||
import type {
|
||||
DashboardCard,
|
||||
DashboardSleepWorkspaceArgs,
|
||||
DashboardSnapshot,
|
||||
DashboardSpawnAgentArgs
|
||||
} from '../../../../shared/dashboard-snapshot'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { lazyWithRetry } from '@/lib/lazy-with-retry'
|
||||
import { AgentDashboardToolbar } from './AgentDashboardToolbar'
|
||||
import { AgentTerminalPanel, type AgentRevealArgs } from './AgentTerminalDialog'
|
||||
import {
|
||||
EMPTY_DASHBOARD_FILTERS,
|
||||
filterDashboardWorkspaces,
|
||||
type DashboardFilters
|
||||
} from './agent-board-filtering'
|
||||
import { selectAgentlessMapWorkspaces } from './agent-map-workspace-visibility'
|
||||
import { useAgentMapStateFilter } from './useAgentMapStateFilter'
|
||||
|
||||
const AgentMap = lazyWithRetry(
|
||||
() => import('./AgentMap').then((module) => ({ default: module.AgentMap })),
|
||||
{ reloadKey: 'agent-map' }
|
||||
)
|
||||
|
||||
type AgentDashboardMapViewProps = {
|
||||
snapshot: DashboardSnapshot
|
||||
cards: DashboardCard[]
|
||||
query: string
|
||||
onQueryChange: (query: string) => void
|
||||
filters: DashboardFilters
|
||||
onFiltersChange: (filters: DashboardFilters) => void
|
||||
searchInputRef: RefObject<HTMLInputElement | null>
|
||||
now: number
|
||||
dialogCard: DashboardCard | null
|
||||
onDialogOpenChange: (open: boolean) => void
|
||||
onRevealAgent: (args: AgentRevealArgs) => void
|
||||
onOpenTerminal: (card: DashboardCard) => void
|
||||
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
|
||||
onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void
|
||||
workspaceContextMenusEnabled: boolean
|
||||
onWorkspaceContextMenuOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
/** Map-only state and derivation stay in the pop-out's lazy chunk. */
|
||||
export function AgentDashboardMapView({
|
||||
snapshot,
|
||||
cards,
|
||||
query,
|
||||
onQueryChange,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
searchInputRef,
|
||||
now,
|
||||
dialogCard,
|
||||
onDialogOpenChange,
|
||||
onRevealAgent,
|
||||
onOpenTerminal,
|
||||
onSpawnAgent,
|
||||
onSleepWorkspace,
|
||||
workspaceContextMenusEnabled,
|
||||
onWorkspaceContextMenuOpenChange
|
||||
}: AgentDashboardMapViewProps): React.JSX.Element {
|
||||
const { agentStates, toggleAgentState, resetAgentStates } = useAgentMapStateFilter()
|
||||
const [showAgentlessWorkspaces, setShowAgentlessWorkspaces] = useState(false)
|
||||
const agentlessWorkspaces = useMemo(
|
||||
() =>
|
||||
selectAgentlessMapWorkspaces({
|
||||
cards: snapshot.cards,
|
||||
workspaces: snapshot.workspaces ?? [],
|
||||
query: '',
|
||||
filters: EMPTY_DASHBOARD_FILTERS
|
||||
}),
|
||||
[snapshot.cards, snapshot.workspaces]
|
||||
)
|
||||
const visibleAgentlessWorkspaces = useMemo(
|
||||
() =>
|
||||
showAgentlessWorkspaces ? filterDashboardWorkspaces(agentlessWorkspaces, query, filters) : [],
|
||||
[agentlessWorkspaces, filters, query, showAgentlessWorkspaces]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<AgentDashboardToolbar
|
||||
cards={snapshot.cards}
|
||||
filterOptions={snapshot.filterOptions}
|
||||
filteredCount={cards.length}
|
||||
query={query}
|
||||
onQueryChange={onQueryChange}
|
||||
filters={filters}
|
||||
onFiltersChange={onFiltersChange}
|
||||
agentStates={agentStates}
|
||||
onAgentStateToggle={toggleAgentState}
|
||||
onAgentStatesReset={resetAgentStates}
|
||||
showAgentlessWorkspaces={showAgentlessWorkspaces}
|
||||
agentlessWorkspaceCount={agentlessWorkspaces.length}
|
||||
onShowAgentlessWorkspacesChange={setShowAgentlessWorkspaces}
|
||||
searchInputRef={searchInputRef}
|
||||
/>
|
||||
<div className={cn('flex min-h-0 flex-1', dialogCard && 'flex-row-reverse')}>
|
||||
<Suspense fallback={null}>
|
||||
<AgentMap
|
||||
cards={cards}
|
||||
workspaces={visibleAgentlessWorkspaces}
|
||||
repoIconsByRepoId={snapshot.repoIconsByRepoId}
|
||||
now={now}
|
||||
className={dialogCard ? 'w-1/2 flex-none' : undefined}
|
||||
compact={dialogCard !== null}
|
||||
selectedPaneKey={dialogCard?.paneKey}
|
||||
enabledStates={agentStates}
|
||||
launchableAgentsByWorktreeId={snapshot.launchableAgentsByWorktreeId}
|
||||
workspaceContextMenusEnabled={workspaceContextMenusEnabled}
|
||||
onWorkspaceContextMenuOpenChange={onWorkspaceContextMenuOpenChange}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
onSpawnAgent={onSpawnAgent}
|
||||
onSleepWorkspace={onSleepWorkspace}
|
||||
/>
|
||||
</Suspense>
|
||||
{dialogCard ? (
|
||||
<AgentTerminalPanel
|
||||
card={dialogCard}
|
||||
onOpenChange={onDialogOpenChange}
|
||||
onReveal={onRevealAgent}
|
||||
className="mr-0 animate-in fade-in-0 slide-in-from-left-2 duration-200 motion-reduce:animate-none"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -43,6 +43,9 @@ type AgentDashboardToolbarProps = {
|
||||
agentStates?: ReadonlySet<AgentMapState>
|
||||
onAgentStateToggle?: (state: AgentMapState) => void
|
||||
onAgentStatesReset?: () => void
|
||||
showAgentlessWorkspaces?: boolean
|
||||
agentlessWorkspaceCount?: number
|
||||
onShowAgentlessWorkspacesChange?: (show: boolean) => void
|
||||
searchInputRef: React.RefObject<HTMLInputElement | null>
|
||||
}
|
||||
|
||||
@@ -169,6 +172,9 @@ export function AgentDashboardToolbar({
|
||||
agentStates,
|
||||
onAgentStateToggle,
|
||||
onAgentStatesReset,
|
||||
showAgentlessWorkspaces,
|
||||
agentlessWorkspaceCount = 0,
|
||||
onShowAgentlessWorkspacesChange,
|
||||
searchInputRef
|
||||
}: AgentDashboardToolbarProps): React.JSX.Element {
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
@@ -181,7 +187,10 @@ export function AgentDashboardToolbar({
|
||||
const agentStateCounts = agentStates ? countAgentMapCards(cards) : null
|
||||
// A muted state is an active filter, so the badge counts them like the rest.
|
||||
const mutedStateCount = agentStates ? AGENT_STATE_ROWS.length - agentStates.size : 0
|
||||
const activeCount = activeDashboardFilterCount(filters) + mutedStateCount
|
||||
const activeCount =
|
||||
activeDashboardFilterCount(filters) +
|
||||
mutedStateCount +
|
||||
(showAgentlessWorkspaces === true ? 1 : 0)
|
||||
const toggleProject = (id: string): void =>
|
||||
onFiltersChange({ ...filters, projects: toggleDashboardFilter(filters.projects, id) })
|
||||
const toggleStatus = (id: string): void =>
|
||||
@@ -197,6 +206,7 @@ export function AgentDashboardToolbar({
|
||||
const clearFilters = (): void => {
|
||||
onFiltersChange({ projects: [], workspaceStatuses: [], reviewStates: [] })
|
||||
onAgentStatesReset?.()
|
||||
onShowAgentlessWorkspacesChange?.(false)
|
||||
}
|
||||
const reviewLabel = (id: DashboardReviewFilter): string =>
|
||||
translate('dashboardPopout.filters.reviewChip', 'Review: {{state}}', {
|
||||
@@ -264,6 +274,27 @@ export function AgentDashboardToolbar({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64" sideOffset={6}>
|
||||
{showAgentlessWorkspaces !== undefined && onShowAgentlessWorkspacesChange ? (
|
||||
<>
|
||||
<DropdownMenuLabel>
|
||||
{translate('dashboardPopout.map.filters.workspaceVisibility', 'Map content')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showAgentlessWorkspaces}
|
||||
onCheckedChange={(checked) => onShowAgentlessWorkspacesChange(checked === true)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
<span className="truncate">
|
||||
{translate(
|
||||
'dashboardPopout.map.filters.agentlessWorkspaces',
|
||||
'Workspaces without agents'
|
||||
)}
|
||||
</span>
|
||||
<OptionCount count={agentlessWorkspaceCount} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
{agentStates && agentStateCounts ? (
|
||||
<>
|
||||
<DropdownMenuLabel>
|
||||
@@ -354,9 +385,11 @@ export function AgentDashboardToolbar({
|
||||
projects={projects}
|
||||
statuses={statuses}
|
||||
reviewLabel={reviewLabel}
|
||||
showAgentlessWorkspaces={showAgentlessWorkspaces === true}
|
||||
onProjectToggle={toggleProject}
|
||||
onStatusToggle={toggleStatus}
|
||||
onReviewToggle={toggleReview}
|
||||
onAgentlessWorkspacesToggle={() => onShowAgentlessWorkspacesChange?.(false)}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -8,12 +8,15 @@ import { resolve } from 'node:path'
|
||||
import type {
|
||||
DashboardCard,
|
||||
DashboardFilterOptions,
|
||||
DashboardSnapshot
|
||||
DashboardSnapshot,
|
||||
DashboardWorkspace
|
||||
} 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', () => ({
|
||||
@@ -92,12 +95,26 @@ 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 }
|
||||
@@ -110,6 +127,10 @@ describe('AgentKanbanBoard', () => {
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage('en')
|
||||
localStorage.clear()
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() }))
|
||||
)
|
||||
// The board relays seen-acks through the dashboard preload API.
|
||||
;(window as unknown as { api: unknown }).api = { dashboard: { ackAgent } }
|
||||
})
|
||||
@@ -118,6 +139,7 @@ describe('AgentKanbanBoard', () => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('renders the three default columns in order', () => {
|
||||
@@ -133,15 +155,17 @@ describe('AgentKanbanBoard', () => {
|
||||
)
|
||||
|
||||
expect(source).toContain("import { lazyWithRetry } from '@/lib/lazy-with-retry'")
|
||||
expect(source).toMatch(/lazyWithRetry\(\s*\(\) => import\('\.\/AgentMap'\)/)
|
||||
expect(source).not.toMatch(/import\s+(?:\{[^}]*\}|\w+)\s+from\s+['"]\.\/AgentMap['"]/)
|
||||
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 () => {
|
||||
renderBoard([])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Agent Map' }))
|
||||
expect(await screen.findByText('0 of 0 agents shown')).toBeInTheDocument()
|
||||
expect(
|
||||
await screen.findByText('0 of 0 agents shown', undefined, 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()
|
||||
@@ -165,7 +189,9 @@ describe('AgentKanbanBoard', () => {
|
||||
})
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Agent Map' }))
|
||||
expect(await screen.findByText('2 of 2 agents shown')).toBeInTheDocument()
|
||||
expect(
|
||||
await screen.findByText('2 of 2 agents shown', undefined, MAP_LOAD_TIMEOUT)
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: /^Filter/ }))
|
||||
fireEvent.click(await screen.findByRole('menuitemcheckbox', { name: /Working/ }))
|
||||
@@ -190,15 +216,58 @@ describe('AgentKanbanBoard', () => {
|
||||
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.pointerDown(screen.getByRole('button', { name: /^Filter/ }))
|
||||
const workspaceToggle = await screen.findByRole('menuitemcheckbox', {
|
||||
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('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/ }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Map agent/ }, MAP_LOAD_TIMEOUT))
|
||||
|
||||
expect(screen.getByLabelText('Nested project, workspace, and agent map')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('terminal-panel')).toHaveAttribute('data-pty-id', 'p1')
|
||||
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()
|
||||
})
|
||||
@@ -207,7 +276,7 @@ describe('AgentKanbanBoard', () => {
|
||||
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/ }))
|
||||
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()
|
||||
@@ -446,7 +515,9 @@ describe('AgentKanbanBoard', () => {
|
||||
<AgentKanbanBoard snapshot={{ generatedAt: 1, cards: [fresh] }} initialView="map" />
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Fresh result/ })).toBeInTheDocument()
|
||||
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')
|
||||
|
||||
|
||||
@@ -14,11 +14,7 @@ import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
|
||||
import { AgentKanbanCard } from './AgentKanbanCard'
|
||||
import { AgentDashboardToolbar } from './AgentDashboardToolbar'
|
||||
import {
|
||||
AgentTerminalDialog,
|
||||
AgentTerminalPanel,
|
||||
type AgentRevealArgs
|
||||
} from './AgentTerminalDialog'
|
||||
import { AgentTerminalDialog, type AgentRevealArgs } from './AgentTerminalDialog'
|
||||
import {
|
||||
EMPTY_DASHBOARD_FILTERS,
|
||||
filterDashboardCards,
|
||||
@@ -28,13 +24,13 @@ import './agent-board-transitions.css'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { lazyWithRetry } from '@/lib/lazy-with-retry'
|
||||
import { useAgentMapStateFilter } from './useAgentMapStateFilter'
|
||||
|
||||
export type AgentDashboardView = 'map' | 'board'
|
||||
|
||||
const AgentMap = lazyWithRetry(
|
||||
() => import('./AgentMap').then((module) => ({ default: module.AgentMap })),
|
||||
{ reloadKey: 'agent-map' }
|
||||
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.
|
||||
@@ -164,6 +160,9 @@ type AgentKanbanBoardProps = {
|
||||
/** 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
|
||||
@@ -182,6 +181,7 @@ export function AgentKanbanBoard({
|
||||
onSleepWorkspace = sleepWorkspaceViaPopoutRelay,
|
||||
onClose,
|
||||
headerActions,
|
||||
onOpenMap,
|
||||
workspaceContextMenusEnabled = false,
|
||||
onWorkspaceContextMenuOpenChange
|
||||
}: AgentKanbanBoardProps): React.JSX.Element {
|
||||
@@ -199,7 +199,6 @@ export function AgentKanbanBoard({
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const [filters, setFilters] = useState<DashboardFilters>(EMPTY_DASHBOARD_FILTERS)
|
||||
const { agentStates, toggleAgentState, resetAgentStates } = useAgentMapStateFilter()
|
||||
const filteredCards = useMemo(
|
||||
() => filterDashboardCards(availableCards, query, filters),
|
||||
[availableCards, filters, query]
|
||||
@@ -210,7 +209,6 @@ export function AgentKanbanBoard({
|
||||
[snapshot.cards]
|
||||
)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const [terminalPanelSide, setTerminalPanelSide] = useState<'left' | 'right'>('right')
|
||||
useEffect(() => {
|
||||
if (!hasRelativeTimestamps) {
|
||||
return
|
||||
@@ -267,13 +265,17 @@ export function AgentKanbanBoard({
|
||||
}, [])
|
||||
const handleViewChange = useCallback(
|
||||
(nextView: AgentDashboardView) => {
|
||||
if (nextView === 'map' && onOpenMap) {
|
||||
onOpenMap()
|
||||
return
|
||||
}
|
||||
if (nextView === view) {
|
||||
return
|
||||
}
|
||||
setOpenedCard(null)
|
||||
setView(nextView)
|
||||
},
|
||||
[view]
|
||||
[onOpenMap, view]
|
||||
)
|
||||
|
||||
// Seen-state is the app-wide ack map (same signal as the sidebar's bold/mute
|
||||
@@ -286,13 +288,6 @@ export function AgentKanbanBoard({
|
||||
},
|
||||
[onAckAgent]
|
||||
)
|
||||
const handleOpenAdjacentTerminal = useCallback(
|
||||
(card: DashboardCard, side: 'left' | 'right') => {
|
||||
setTerminalPanelSide(side)
|
||||
handleOpenTerminal(card)
|
||||
},
|
||||
[handleOpenTerminal]
|
||||
)
|
||||
// Watching the open dialog counts as seeing state changes as they happen —
|
||||
// without this, an agent finishing while you watch would re-flag its card.
|
||||
useEffect(() => {
|
||||
@@ -362,76 +357,55 @@ export function AgentKanbanBoard({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<AgentDashboardToolbar
|
||||
cards={visibleCards}
|
||||
filterOptions={snapshot.filterOptions}
|
||||
filteredCount={filteredCards.length}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
agentStates={view === 'map' ? agentStates : undefined}
|
||||
onAgentStateToggle={toggleAgentState}
|
||||
onAgentStatesReset={resetAgentStates}
|
||||
searchInputRef={searchInputRef}
|
||||
/>
|
||||
{view !== 'board' ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1',
|
||||
dialogCard && terminalPanelSide === 'left' && 'flex-row-reverse'
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<AgentMap
|
||||
cards={filteredCards}
|
||||
repoIconsByRepoId={snapshot.repoIconsByRepoId}
|
||||
now={now}
|
||||
className={
|
||||
dialogCard
|
||||
? 'w-[clamp(14rem,28vw,22rem)] flex-none transition-[width] duration-200 motion-reduce:transition-none'
|
||||
: undefined
|
||||
}
|
||||
compact={dialogCard !== null}
|
||||
selectedPaneKey={dialogCard?.paneKey}
|
||||
enabledStates={agentStates}
|
||||
launchableAgentsByWorktreeId={snapshot.launchableAgentsByWorktreeId}
|
||||
workspaceContextMenusEnabled={workspaceContextMenusEnabled}
|
||||
onWorkspaceContextMenuOpenChange={onWorkspaceContextMenuOpenChange}
|
||||
onOpenTerminal={handleOpenAdjacentTerminal}
|
||||
onSpawnAgent={onSpawnAgent}
|
||||
onSleepWorkspace={onSleepWorkspace}
|
||||
/>
|
||||
</Suspense>
|
||||
{dialogCard ? (
|
||||
<AgentTerminalPanel
|
||||
card={dialogCard}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
onReveal={onRevealAgent}
|
||||
className={cn(
|
||||
terminalPanelSide === 'right' ? 'ml-0' : 'mr-0',
|
||||
'animate-in fade-in-0 duration-200 motion-reduce:animate-none',
|
||||
terminalPanelSide === 'right' ? 'slide-in-from-right-2' : 'slide-in-from-left-2'
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<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>
|
||||
) : (
|
||||
<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}
|
||||
/>
|
||||
))}
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{view === 'board' ? (
|
||||
<AgentTerminalDialog
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from '../../../../shared/dashboard-snapshot'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import { AgentMap } from './AgentMap'
|
||||
import { agentMapAttentionMarkerScale } from './AgentMapWorktreeRingNode'
|
||||
import type { AgentMapState } from './agent-map-filter'
|
||||
import { AGENT_MAP_AGENT_RADIUS } from './agent-map-layout'
|
||||
|
||||
@@ -52,7 +53,7 @@ function renderMap(
|
||||
onSpawnAgent,
|
||||
onSleepWorkspace
|
||||
}: {
|
||||
onOpenTerminal?: (card: DashboardCard, side: 'left' | 'right') => void
|
||||
onOpenTerminal?: (card: DashboardCard) => void
|
||||
selectedPaneKey?: string | null
|
||||
compact?: boolean
|
||||
workspaceContextMenusEnabled?: boolean
|
||||
@@ -84,6 +85,10 @@ describe('AgentMap', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Linux' })
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() }))
|
||||
)
|
||||
boundsSpy = vi
|
||||
.spyOn(Element.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function getBounds(this: Element) {
|
||||
@@ -125,7 +130,7 @@ describe('AgentMap', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the fixed amber marker only for unread agents', () => {
|
||||
it('renders the amber marker only for unread agents', () => {
|
||||
const finished = card({
|
||||
paneKey: 'done',
|
||||
conversationName: 'Finished agent',
|
||||
@@ -149,10 +154,46 @@ describe('AgentMap', () => {
|
||||
const onRing = String(-AGENT_MAP_AGENT_RADIUS * Math.SQRT1_2)
|
||||
expect(unreadMarker).toHaveAttribute('cx', onRing)
|
||||
expect(unreadMarker).toHaveAttribute('cy', onRing)
|
||||
expect(unreadMarker).toHaveAttribute('r', '4.5')
|
||||
expect(Number(unreadMarker?.getAttribute('r'))).toBeGreaterThanOrEqual(
|
||||
AGENT_MAP_AGENT_RADIUS * 0.225
|
||||
)
|
||||
expect(unreadMarker).toHaveAttribute('vector-effect', 'none')
|
||||
expect(doneNode).toHaveAccessibleName(/unread/)
|
||||
})
|
||||
|
||||
it('enlarges unread markers when the full fleet is zoomed out', () => {
|
||||
const fleet = Array.from({ length: 72 }, (_, index) =>
|
||||
card({
|
||||
paneKey: `pane-${index}`,
|
||||
ptyId: `pty-${index}`,
|
||||
tabId: `tab-${index}`,
|
||||
leafId: `leaf-${index}`,
|
||||
worktreeId: `worktree-${index}`,
|
||||
worktreeName: `Worktree ${index}`,
|
||||
conversationName: `Agent ${index}`,
|
||||
unseen: index === 0
|
||||
})
|
||||
)
|
||||
const { container } = renderMap(fleet)
|
||||
const marker = container.querySelector<SVGCircleElement>('[data-agent-unread-marker]')!
|
||||
const agentMark =
|
||||
marker.parentElement!.querySelector<SVGCircleElement>('.agent-map-agent-mark')!
|
||||
|
||||
expect(Number(marker.getAttribute('r'))).toBeGreaterThan(
|
||||
Number(agentMark.getAttribute('r')) * 0.225
|
||||
)
|
||||
expect(marker).toHaveAttribute('vector-effect', 'none')
|
||||
})
|
||||
|
||||
it('grows attention markers more gently than the inverse zoom while keeping a size floor', () => {
|
||||
const mapScale = 0.2
|
||||
const markerScale = agentMapAttentionMarkerScale(mapScale)
|
||||
|
||||
expect(markerScale).toBeGreaterThan(1)
|
||||
expect(markerScale).toBeLessThan(1 / mapScale)
|
||||
expect(AGENT_MAP_AGENT_RADIUS * 0.225 * markerScale * mapScale).toBeGreaterThanOrEqual(2.25)
|
||||
})
|
||||
|
||||
it('shows worktree details and opens a running agent', () => {
|
||||
const onOpenTerminal = vi.fn()
|
||||
const running = card()
|
||||
@@ -167,7 +208,7 @@ describe('AgentMap', () => {
|
||||
expect(screen.getByText('1 agent · 1 active · 0 done')).toBeInTheDocument()
|
||||
expect(screen.getByText('Agent alpha')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Agent alpha/ })[1])
|
||||
expect(onOpenTerminal).toHaveBeenCalledWith(running, 'right')
|
||||
expect(onOpenTerminal).toHaveBeenCalledWith(running)
|
||||
expect(ring).not.toHaveClass('is-open')
|
||||
})
|
||||
|
||||
@@ -228,7 +269,7 @@ describe('AgentMap', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('connects orchestrated workers beneath their visible parent', () => {
|
||||
it('connects spawned workers beneath their visible parent', () => {
|
||||
const parent = card({ paneKey: 'parent', conversationName: 'Coordinator' })
|
||||
const child = card({
|
||||
paneKey: 'child',
|
||||
@@ -246,10 +287,33 @@ describe('AgentMap', () => {
|
||||
expect(screen.getByRole('button', { name: /Coordinator/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /^Worker/ })).toBeInTheDocument()
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0]).toHaveClass('agent-map-lineage-link')
|
||||
expect(links[0]).toHaveAttribute('data-parent-pane-key', 'parent')
|
||||
expect(links[0]).toHaveAttribute('data-child-pane-key', 'child')
|
||||
})
|
||||
|
||||
it('connects spawned workers across worktree rings', () => {
|
||||
const parent = card({
|
||||
paneKey: 'parent',
|
||||
worktreeId: 'parent-worktree',
|
||||
worktreeName: 'Parent workspace',
|
||||
conversationName: 'Coordinator'
|
||||
})
|
||||
const child = card({
|
||||
paneKey: 'child',
|
||||
worktreeId: 'child-worktree',
|
||||
worktreeName: 'Child workspace',
|
||||
parentPaneKey: 'parent',
|
||||
conversationName: 'Worker'
|
||||
})
|
||||
const { container } = renderMap([parent, child])
|
||||
const link = container.querySelector('[data-agent-map-cross-worktree-lineage-link]')
|
||||
|
||||
expect(link).toHaveClass('agent-map-lineage-link', 'is-cross-worktree')
|
||||
expect(link).toHaveAttribute('data-parent-pane-key', 'parent')
|
||||
expect(link).toHaveAttribute('data-child-pane-key', 'child')
|
||||
})
|
||||
|
||||
it('connects visible child worktrees beneath their parent ring', () => {
|
||||
const { container } = renderMap([
|
||||
card({ paneKey: 'parent', worktreeId: 'parent-worktree', worktreeName: 'Parent' }),
|
||||
@@ -279,17 +343,59 @@ describe('AgentMap', () => {
|
||||
renderMap([agent], { onOpenTerminal })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Agent alpha/ }))
|
||||
expect(onOpenTerminal).toHaveBeenCalledWith(agent, 'right')
|
||||
expect(onOpenTerminal).toHaveBeenCalledWith(agent)
|
||||
})
|
||||
|
||||
it('keeps a selected node visible while compacting around an adjacent terminal', () => {
|
||||
renderMap([card()], { selectedPaneKey: 'pane-1', compact: true })
|
||||
const { container } = renderMap([card()], { selectedPaneKey: 'pane-1', compact: true })
|
||||
|
||||
expect(screen.getByRole('button', { name: /Agent alpha/ })).toHaveClass('is-selected')
|
||||
const selectedNode = screen.getByRole('button', { name: /Agent alpha/ })
|
||||
expect(selectedNode).toHaveClass('is-selected')
|
||||
expect(screen.getByRole('button', { name: /Agent alpha/ })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
)
|
||||
const label = container.querySelector('.agent-map-worktree-label-group')!
|
||||
expect(selectedNode.compareDocumentPosition(label) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(4)
|
||||
expect(label.querySelector('rect')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('270%')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Map filters')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('group', { name: 'Host filter' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('eases the viewport into the selected agent', () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }))
|
||||
)
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
const agent = card()
|
||||
const view = renderMap([agent])
|
||||
|
||||
view.rerender(
|
||||
<AgentMap
|
||||
cards={[agent]}
|
||||
now={NOW}
|
||||
onOpenTerminal={vi.fn()}
|
||||
selectedPaneKey={agent.paneKey}
|
||||
/>
|
||||
)
|
||||
act(() => frames.shift()?.(0))
|
||||
expect(screen.getByText('100%')).toBeInTheDocument()
|
||||
act(() => frames.shift()?.(120))
|
||||
expect(screen.getByText('249%')).toBeInTheDocument()
|
||||
act(() => frames.shift()?.(240))
|
||||
expect(screen.getByText('270%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the selected node centered when topology changes around it', () => {
|
||||
const selected = card()
|
||||
const view = renderMap([selected], { selectedPaneKey: selected.paneKey })
|
||||
|
||||
@@ -6,7 +6,8 @@ import type {
|
||||
DashboardCard,
|
||||
DashboardCardHostKind,
|
||||
DashboardSleepWorkspaceArgs,
|
||||
DashboardSpawnAgentArgs
|
||||
DashboardSpawnAgentArgs,
|
||||
DashboardWorkspace
|
||||
} from '../../../../shared/dashboard-snapshot'
|
||||
import type { RepoIcon } from '../../../../shared/repo-icon'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
@@ -21,6 +22,7 @@ import './agent-map.css'
|
||||
|
||||
type AgentMapProps = {
|
||||
cards: DashboardCard[]
|
||||
workspaces?: DashboardWorkspace[]
|
||||
repoIconsByRepoId?: Record<string, RepoIcon | null>
|
||||
now: number
|
||||
className?: string
|
||||
@@ -32,7 +34,7 @@ type AgentMapProps = {
|
||||
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
|
||||
workspaceContextMenusEnabled?: boolean
|
||||
onWorkspaceContextMenuOpenChange?: (open: boolean) => void
|
||||
onOpenTerminal: (card: DashboardCard, side: 'left' | 'right') => void
|
||||
onOpenTerminal: (card: DashboardCard) => void
|
||||
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
|
||||
onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void
|
||||
}
|
||||
@@ -44,6 +46,7 @@ const ALL_AGENT_STATES: ReadonlySet<AgentMapState> = new Set<AgentMapState>([
|
||||
'done',
|
||||
'idle'
|
||||
])
|
||||
const EMPTY_WORKSPACES: DashboardWorkspace[] = []
|
||||
|
||||
function hostFilterLabel(filter: AgentMapHostFilter): string {
|
||||
switch (filter) {
|
||||
@@ -62,6 +65,7 @@ function hostFilterLabel(filter: AgentMapHostFilter): string {
|
||||
|
||||
export function AgentMap({
|
||||
cards,
|
||||
workspaces = EMPTY_WORKSPACES,
|
||||
repoIconsByRepoId,
|
||||
now,
|
||||
className,
|
||||
@@ -88,8 +92,11 @@ export function AgentMap({
|
||||
for (const card of cards) {
|
||||
counts[card.hostKind ?? 'local'] += 1
|
||||
}
|
||||
for (const workspace of workspaces) {
|
||||
counts[workspace.hostKind] += 1
|
||||
}
|
||||
return counts
|
||||
}, [cards])
|
||||
}, [cards, workspaces])
|
||||
const visibleCards = useMemo(
|
||||
() =>
|
||||
filterAgentMapCards({
|
||||
@@ -99,9 +106,16 @@ export function AgentMap({
|
||||
}),
|
||||
[cards, enabledStates, hostFilter]
|
||||
)
|
||||
const visibleWorkspaces = useMemo(
|
||||
() =>
|
||||
hostFilter === 'all'
|
||||
? workspaces
|
||||
: workspaces.filter((workspace) => workspace.hostKind === hostFilter),
|
||||
[hostFilter, workspaces]
|
||||
)
|
||||
const layoutResult = useMemo(
|
||||
() => updateAgentMapLayout(layoutCacheRef.current, visibleCards, now),
|
||||
[visibleCards, now]
|
||||
() => updateAgentMapLayout(layoutCacheRef.current, visibleCards, now, visibleWorkspaces),
|
||||
[visibleCards, visibleWorkspaces, now]
|
||||
)
|
||||
useEffect(() => {
|
||||
layoutCacheRef.current = layoutResult.cache
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useState
|
||||
} from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'
|
||||
import type {
|
||||
DashboardCard,
|
||||
DashboardSleepWorkspaceArgs,
|
||||
@@ -15,7 +16,12 @@ import type {
|
||||
} from '../../../../shared/dashboard-snapshot'
|
||||
import type { RepoIcon } from '../../../../shared/repo-icon'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import type { AgentMapAgentNode, AgentMapProjectRing, AgentMapLayout } from './agent-map-layout'
|
||||
import {
|
||||
AGENT_MAP_AGENT_RADIUS,
|
||||
type AgentMapAgentNode,
|
||||
type AgentMapProjectRing,
|
||||
type AgentMapLayout
|
||||
} from './agent-map-layout'
|
||||
import { AgentMapScene } from './AgentMapScene'
|
||||
import { AgentMapViewportControls } from './AgentMapViewportControls'
|
||||
import {
|
||||
@@ -23,14 +29,18 @@ import {
|
||||
navigableAgentMapAgents,
|
||||
nextDirectionalAgent
|
||||
} from './agent-map-navigation'
|
||||
import type { AgentMapViewport } from './agent-map-viewport-transition'
|
||||
import { useAgentMapContextMenus } from './useAgentMapContextMenus'
|
||||
import { useAgentMapCanvasSize } from './useAgentMapCanvasSize'
|
||||
import { useAgentMapSelectedFocus } from './useAgentMapSelectedFocus'
|
||||
import { useAgentMapViewportTransition } from './useAgentMapViewportTransition'
|
||||
|
||||
const MIN_ZOOM = 0.7
|
||||
const MAX_ZOOM = 4
|
||||
const MAX_ZOOM = 24
|
||||
const AGENT_FOCUS_DURATION_MS = 240
|
||||
const AGENT_FOCUS_RADIUS_PX = 24
|
||||
|
||||
type Point = { x: number; y: number }
|
||||
type ViewportSize = { width: number; height: number }
|
||||
type Viewport = { center: Point; zoom: number }
|
||||
|
||||
export type AgentMapCanvasHandle = {
|
||||
fit: () => void
|
||||
@@ -45,7 +55,7 @@ type AgentMapCanvasProps = {
|
||||
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
|
||||
workspaceContextMenusEnabled?: boolean
|
||||
onWorkspaceContextMenuOpenChange?: (open: boolean) => void
|
||||
onSelectAgent: (card: DashboardCard, side: 'left' | 'right') => void
|
||||
onSelectAgent: (card: DashboardCard) => void
|
||||
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
|
||||
onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void
|
||||
}
|
||||
@@ -54,6 +64,19 @@ function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value))
|
||||
}
|
||||
|
||||
function agentFocusZoom(layout: AgentMapLayout, width: number, height: number): number {
|
||||
const aspect = width / Math.max(1, height)
|
||||
const baseWidth = Math.max(layout.width, layout.height * aspect)
|
||||
return clamp(
|
||||
Math.max(
|
||||
2,
|
||||
(baseWidth * AGENT_FOCUS_RADIUS_PX) / (Math.max(1, width) * AGENT_MAP_AGENT_RADIUS)
|
||||
),
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM
|
||||
)
|
||||
}
|
||||
|
||||
export const AgentMapCanvas = forwardRef<AgentMapCanvasHandle, AgentMapCanvasProps>(
|
||||
function AgentMapCanvas(
|
||||
{
|
||||
@@ -81,15 +104,18 @@ export const AgentMapCanvas = forwardRef<AgentMapCanvasHandle, AgentMapCanvasPro
|
||||
worldPerPixelY: number
|
||||
} | null>(null)
|
||||
const viewportFrameRef = useRef<number | null>(null)
|
||||
const pendingViewportRef = useRef<Viewport | null>(null)
|
||||
const pendingViewportRef = useRef<AgentMapViewport | null>(null)
|
||||
const interactionBoundsRef = useRef<DOMRect | null>(null)
|
||||
const focusedAgentRef = useRef<{ paneKey: string; x: number; y: number } | null>(null)
|
||||
const hasShownProjectsRef = useRef(layout.projects.length > 0)
|
||||
const [size, setSize] = useState<ViewportSize>({ width: 800, height: 560 })
|
||||
const [viewport, setViewport] = useState<Viewport>({
|
||||
const clearInteractionBounds = useCallback(() => {
|
||||
interactionBoundsRef.current = null
|
||||
}, [])
|
||||
const size = useAgentMapCanvasSize(containerRef, clearInteractionBounds)
|
||||
const [viewport, setViewport] = useState<AgentMapViewport>({
|
||||
center: { x: layout.width / 2, y: layout.height / 2 },
|
||||
zoom: 1
|
||||
})
|
||||
const prefersReducedMotion = usePrefersReducedMotion()
|
||||
const viewportRef = useRef(viewport)
|
||||
const { contextMenus, onOpenProjectContextMenu, onOpenWorkspaceContextMenu } =
|
||||
useAgentMapContextMenus({
|
||||
@@ -114,29 +140,61 @@ export const AgentMapCanvas = forwardRef<AgentMapCanvasHandle, AgentMapCanvasPro
|
||||
const mapScale = size.width / viewWidth
|
||||
const labelScale = Math.max(1, 1 / mapScale)
|
||||
const viewBox = `${center.x - viewWidth / 2} ${center.y - viewHeight / 2} ${viewWidth} ${viewHeight}`
|
||||
const focusZoom = agentFocusZoom(layout, size.width, size.height)
|
||||
const resolveFocusZoom = useCallback((): number => {
|
||||
const bounds = containerRef.current?.getBoundingClientRect()
|
||||
return bounds && bounds.width > 0 && bounds.height > 0
|
||||
? agentFocusZoom(layout, bounds.width, bounds.height)
|
||||
: focusZoom
|
||||
}, [focusZoom, layout])
|
||||
|
||||
const applyViewport = useCallback((next: Viewport): void => {
|
||||
const commitViewport = useCallback((next: AgentMapViewport): void => {
|
||||
viewportRef.current = next
|
||||
pendingViewportRef.current = null
|
||||
interactionBoundsRef.current = null
|
||||
setViewport(next)
|
||||
}, [])
|
||||
const scheduleViewport = useCallback((next: Viewport): void => {
|
||||
viewportRef.current = next
|
||||
pendingViewportRef.current = next
|
||||
if (viewportFrameRef.current !== null) {
|
||||
return
|
||||
}
|
||||
viewportFrameRef.current = requestAnimationFrame(() => {
|
||||
viewportFrameRef.current = null
|
||||
interactionBoundsRef.current = null
|
||||
const pending = pendingViewportRef.current
|
||||
pendingViewportRef.current = null
|
||||
if (pending) {
|
||||
setViewport(pending)
|
||||
}
|
||||
const { animate: animateViewport, stop: stopViewportTransition } =
|
||||
useAgentMapViewportTransition({
|
||||
durationMs: AGENT_FOCUS_DURATION_MS,
|
||||
reducedMotion: prefersReducedMotion,
|
||||
onFrame: commitViewport
|
||||
})
|
||||
}, [])
|
||||
useAgentMapSelectedFocus({
|
||||
agents,
|
||||
selectedPaneKey,
|
||||
viewportRef,
|
||||
resolveFocusZoom,
|
||||
animateViewport,
|
||||
stopViewportTransition
|
||||
})
|
||||
const applyViewport = useCallback(
|
||||
(next: AgentMapViewport): void => {
|
||||
stopViewportTransition()
|
||||
commitViewport(next)
|
||||
},
|
||||
[commitViewport, stopViewportTransition]
|
||||
)
|
||||
const scheduleViewport = useCallback(
|
||||
(next: AgentMapViewport): void => {
|
||||
stopViewportTransition()
|
||||
viewportRef.current = next
|
||||
pendingViewportRef.current = next
|
||||
if (viewportFrameRef.current !== null) {
|
||||
return
|
||||
}
|
||||
viewportFrameRef.current = requestAnimationFrame(() => {
|
||||
viewportFrameRef.current = null
|
||||
interactionBoundsRef.current = null
|
||||
const pending = pendingViewportRef.current
|
||||
pendingViewportRef.current = null
|
||||
if (pending) {
|
||||
setViewport(pending)
|
||||
}
|
||||
})
|
||||
},
|
||||
[stopViewportTransition]
|
||||
)
|
||||
const fit = useCallback((): void => {
|
||||
applyViewport({ center: { x: layout.width / 2, y: layout.height / 2 }, zoom: 1 })
|
||||
}, [applyViewport, layout.height, layout.width])
|
||||
@@ -164,28 +222,6 @@ export const AgentMapCanvas = forwardRef<AgentMapCanvasHandle, AgentMapCanvasPro
|
||||
}
|
||||
}, [fit, hasProjects])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
const measure = (): void => {
|
||||
const next = container.getBoundingClientRect()
|
||||
if (next.width > 0 && next.height > 0) {
|
||||
interactionBoundsRef.current = null
|
||||
setSize((current) =>
|
||||
current.width === next.width && current.height === next.height
|
||||
? current
|
||||
: { width: next.width, height: next.height }
|
||||
)
|
||||
}
|
||||
}
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(container)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (viewportFrameRef.current !== null) {
|
||||
@@ -195,38 +231,11 @@ export const AgentMapCanvas = forwardRef<AgentMapCanvasHandle, AgentMapCanvasPro
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPaneKey) {
|
||||
focusedAgentRef.current = null
|
||||
return
|
||||
}
|
||||
const selected = agents.find((agent) => agent.card.paneKey === selectedPaneKey)
|
||||
if (!selected) {
|
||||
focusedAgentRef.current = null
|
||||
return
|
||||
}
|
||||
const focused = focusedAgentRef.current
|
||||
if (
|
||||
focused?.paneKey === selectedPaneKey &&
|
||||
focused.x === selected.x &&
|
||||
focused.y === selected.y
|
||||
) {
|
||||
return
|
||||
}
|
||||
focusedAgentRef.current = { paneKey: selectedPaneKey, x: selected.x, y: selected.y }
|
||||
applyViewport({
|
||||
center: { x: selected.x, y: selected.y },
|
||||
zoom: Math.max(1, viewportRef.current.zoom)
|
||||
})
|
||||
}, [agents, applyViewport, selectedPaneKey])
|
||||
|
||||
const handleAgentKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<SVGGElement>, agent: AgentMapAgentNode): void => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
const bounds = event.currentTarget.getBoundingClientRect()
|
||||
const side = bounds.left + bounds.width / 2 <= window.innerWidth / 2 ? 'right' : 'left'
|
||||
onSelectAgent(agent.card, side)
|
||||
onSelectAgent(agent.card)
|
||||
return
|
||||
}
|
||||
const direction =
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('AgentMapScene project labels', () => {
|
||||
}}
|
||||
zoom={1}
|
||||
labelScale={1}
|
||||
mapScale={1}
|
||||
mapScale={0.5}
|
||||
selectedPaneKey={null}
|
||||
allowAggregation
|
||||
nodeRefs={{ current: new Map() }}
|
||||
@@ -50,7 +50,11 @@ describe('AgentMapScene project labels', () => {
|
||||
|
||||
const label = container.querySelector('.agent-map-project-label')!
|
||||
expect(label).toHaveTextContent('ORCA')
|
||||
expect(label).toHaveClass('agent-map-project-label')
|
||||
expect(label.querySelector('.agent-map-project-name')).toHaveTextContent('ORCA')
|
||||
expect(label.querySelector('img')).toHaveAttribute('src', 'data:image/png;base64,AAAA')
|
||||
expect(label.firstElementChild?.querySelector('img')).toBeInTheDocument()
|
||||
expect(container.querySelector('.agent-map-project-label-frame')).toHaveAttribute('x', '-48')
|
||||
expect(container.querySelector('.agent-map-project-label-frame')).toHaveAttribute('width', '96')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useMemo, type MutableRefObject } from 'react'
|
||||
import { memo, useCallback, useMemo, useState, type MutableRefObject } from 'react'
|
||||
import { RepoIconGlyph } from '@/components/repo/repo-icon'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { DashboardCard, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot'
|
||||
@@ -10,7 +10,9 @@ import type {
|
||||
AgentMapProjectRing,
|
||||
AgentMapWorktreeRing
|
||||
} from './agent-map-layout'
|
||||
import { shouldAggregateAgentMapWorktree } from './agent-map-layout'
|
||||
import { selectVisibleAgentMapLabels } from './agent-map-label-declutter'
|
||||
import { AgentMapWorktreeLabel } from './AgentMapWorktreeLabel'
|
||||
import { AgentMapWorktreeRingNode } from './AgentMapWorktreeRingNode'
|
||||
|
||||
type AgentMapSceneProps = {
|
||||
@@ -23,7 +25,7 @@ type AgentMapSceneProps = {
|
||||
allowAggregation: boolean
|
||||
launchableAgentsByWorktreeId?: Record<string, TuiAgent[]>
|
||||
nodeRefs: MutableRefObject<Map<string, SVGGElement>>
|
||||
onSelectAgent: (card: DashboardCard, side: 'left' | 'right') => void
|
||||
onSelectAgent: (card: DashboardCard) => void
|
||||
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
|
||||
onOpenProjectContextMenu?: (
|
||||
event: React.MouseEvent<SVGCircleElement>,
|
||||
@@ -43,6 +45,23 @@ function worktreeLineagePath(parent: AgentMapWorktreeRing, child: AgentMapWorktr
|
||||
return `M ${parent.x} ${startY} C ${parent.x} ${branchY} ${child.x} ${branchY} ${child.x} ${endY}`
|
||||
}
|
||||
|
||||
type VisibleAgentLocation = {
|
||||
agent: AgentMapAgentNode
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
function agentLineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): string {
|
||||
const dx = child.x - parent.x
|
||||
const dy = child.y - parent.y
|
||||
const distance = Math.hypot(dx, dy)
|
||||
if (distance === 0) {
|
||||
return `M ${parent.x} ${parent.y}`
|
||||
}
|
||||
const unitX = dx / distance
|
||||
const unitY = dy / distance
|
||||
return `M ${parent.x + unitX * parent.radius} ${parent.y + unitY * parent.radius} L ${child.x - unitX * child.radius} ${child.y - unitY * child.radius}`
|
||||
}
|
||||
|
||||
/** Memoization keeps pointer panning to one SVG viewBox write, not a map rerender. */
|
||||
export const AgentMapScene = memo(function AgentMapScene({
|
||||
layout,
|
||||
@@ -60,14 +79,52 @@ export const AgentMapScene = memo(function AgentMapScene({
|
||||
onOpenWorkspaceContextMenu,
|
||||
onAgentKeyDown
|
||||
}: AgentMapSceneProps): React.JSX.Element {
|
||||
const [activeWorktreeId, setActiveWorktreeId] = useState<string | null>(null)
|
||||
const handleLabelActiveChange = useCallback((worktreeId: string, active: boolean): void => {
|
||||
setActiveWorktreeId((current) =>
|
||||
active ? worktreeId : current === worktreeId ? null : current
|
||||
)
|
||||
}, [])
|
||||
const visibleLabels = useMemo(
|
||||
() => selectVisibleAgentMapLabels(layout, labelScale, mapScale),
|
||||
[labelScale, layout, mapScale]
|
||||
)
|
||||
const visibleAgentsByPaneKey = useMemo(() => {
|
||||
const agents = new Map<string, VisibleAgentLocation>()
|
||||
for (const project of layout.projects) {
|
||||
for (const worktree of project.worktrees) {
|
||||
const selected = worktree.agents.some((agent) => agent.card.paneKey === selectedPaneKey)
|
||||
if (!selected && shouldAggregateAgentMapWorktree(worktree, zoom, allowAggregation)) {
|
||||
continue
|
||||
}
|
||||
for (const agent of worktree.agents) {
|
||||
agents.set(agent.card.paneKey, { agent, worktreeId: worktree.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
return agents
|
||||
}, [allowAggregation, layout, selectedPaneKey, zoom])
|
||||
return (
|
||||
<>
|
||||
{layout.projects.map((project) => {
|
||||
const worktreesById = new Map(project.worktrees.map((worktree) => [worktree.id, worktree]))
|
||||
const projectLabelHalfWidth = project.radius * mapScale
|
||||
const projectCountText = translate(
|
||||
'dashboardPopout.map.projectCount',
|
||||
'{{agents}} agents · {{workspaces}} workspaces',
|
||||
{ agents: project.agentCount, workspaces: project.worktrees.length }
|
||||
).toUpperCase()
|
||||
const crossWorktreeLineage = project.worktrees.flatMap((worktree) =>
|
||||
worktree.agents.flatMap((child) => {
|
||||
const parent = child.card.parentPaneKey
|
||||
? visibleAgentsByPaneKey.get(child.card.parentPaneKey)
|
||||
: undefined
|
||||
const childLocation = visibleAgentsByPaneKey.get(child.card.paneKey)
|
||||
return parent && childLocation && parent.worktreeId !== childLocation.worktreeId
|
||||
? [{ parent: parent.agent, child }]
|
||||
: []
|
||||
})
|
||||
)
|
||||
return (
|
||||
<g key={project.id}>
|
||||
<circle
|
||||
@@ -86,35 +143,6 @@ export const AgentMapScene = memo(function AgentMapScene({
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<g
|
||||
transform={`translate(${project.x} ${project.y - project.radius}) scale(${labelScale})`}
|
||||
>
|
||||
<foreignObject
|
||||
className="agent-map-project-label-frame"
|
||||
x={-project.radius}
|
||||
y={3}
|
||||
width={project.radius * 2}
|
||||
height={18}
|
||||
>
|
||||
<div className="agent-map-project-label">
|
||||
<RepoIconGlyph
|
||||
repoIcon={repoIconsByRepoId?.[project.id] ?? null}
|
||||
className="size-3 shrink-0"
|
||||
iconClassName="size-3"
|
||||
/>
|
||||
<span className="shrink-0">{project.name.toUpperCase()}</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
{visibleLabels.projectCountIds.has(project.id) ? (
|
||||
<text className="agent-map-project-count" y={32}>
|
||||
{translate(
|
||||
'dashboardPopout.map.projectCount',
|
||||
'{{agents}} agents · {{workspaces}} workspaces',
|
||||
{ agents: project.agentCount, workspaces: project.worktrees.length }
|
||||
).toUpperCase()}
|
||||
</text>
|
||||
) : null}
|
||||
</g>
|
||||
<g className="agent-map-worktree-lineage-links" aria-hidden>
|
||||
{project.worktrees.map((child) => {
|
||||
const parent = child.parentId ? worktreesById.get(child.parentId) : undefined
|
||||
@@ -130,25 +158,79 @@ export const AgentMapScene = memo(function AgentMapScene({
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
<g className="agent-map-lineage-links" aria-hidden>
|
||||
{crossWorktreeLineage.map(({ parent, child }) => (
|
||||
<path
|
||||
key={child.card.paneKey}
|
||||
className="agent-map-lineage-link is-cross-worktree"
|
||||
data-agent-map-lineage-link=""
|
||||
data-agent-map-cross-worktree-lineage-link=""
|
||||
data-parent-pane-key={parent.card.paneKey}
|
||||
data-child-pane-key={child.card.paneKey}
|
||||
d={agentLineagePath(parent, child)}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
{project.worktrees.map((worktree) => (
|
||||
<AgentMapWorktreeRingNode
|
||||
key={worktree.id}
|
||||
project={project}
|
||||
worktree={worktree}
|
||||
zoom={zoom}
|
||||
labelScale={labelScale}
|
||||
mapScale={mapScale}
|
||||
selectedPaneKey={selectedPaneKey}
|
||||
allowAggregation={allowAggregation}
|
||||
labelVisible={visibleLabels.worktreeIds.has(worktree.id)}
|
||||
launchableAgents={launchableAgentsByWorktreeId?.[worktree.worktreeId]}
|
||||
nodeRefs={nodeRefs}
|
||||
onSelectAgent={onSelectAgent}
|
||||
onSpawnAgent={onSpawnAgent}
|
||||
onOpenWorkspaceContextMenu={onOpenWorkspaceContextMenu}
|
||||
onLabelActiveChange={handleLabelActiveChange}
|
||||
onAgentKeyDown={onAgentKeyDown}
|
||||
/>
|
||||
))}
|
||||
<g className="agent-map-worktree-label-layer">
|
||||
{project.worktrees.map((worktree) => (
|
||||
<AgentMapWorktreeLabel
|
||||
key={worktree.id}
|
||||
worktree={worktree}
|
||||
visible={visibleLabels.worktreeIds.has(worktree.id)}
|
||||
active={
|
||||
activeWorktreeId === worktree.id &&
|
||||
visibleLabels.worktreeAgentSafeIds.has(worktree.id)
|
||||
}
|
||||
labelScale={labelScale}
|
||||
mapScale={mapScale}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
<g
|
||||
transform={`translate(${project.x} ${project.y - project.radius}) scale(${labelScale})`}
|
||||
>
|
||||
<foreignObject
|
||||
className="agent-map-project-label-frame"
|
||||
x={-projectLabelHalfWidth}
|
||||
y={3}
|
||||
width={projectLabelHalfWidth * 2}
|
||||
height={18}
|
||||
>
|
||||
<div className="agent-map-project-label">
|
||||
<RepoIconGlyph
|
||||
repoIcon={repoIconsByRepoId?.[project.id] ?? null}
|
||||
className="size-3 shrink-0"
|
||||
iconClassName="size-3"
|
||||
/>
|
||||
<span className="agent-map-project-name min-w-0 truncate">
|
||||
{project.name.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
{visibleLabels.projectCountIds.has(project.id) ? (
|
||||
<text className="agent-map-project-count" y={32}>
|
||||
{projectCountText}
|
||||
</text>
|
||||
) : null}
|
||||
</g>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { memo } from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { AgentMapWorktreeRing } from './agent-map-layout'
|
||||
|
||||
type AgentMapWorktreeLabelProps = {
|
||||
worktree: AgentMapWorktreeRing
|
||||
visible: boolean
|
||||
active: boolean
|
||||
labelScale: number
|
||||
mapScale: number
|
||||
}
|
||||
|
||||
export const AgentMapWorktreeLabel = memo(function AgentMapWorktreeLabel({
|
||||
worktree,
|
||||
visible,
|
||||
active,
|
||||
labelScale,
|
||||
mapScale
|
||||
}: AgentMapWorktreeLabelProps): React.JSX.Element {
|
||||
const showCount = visible && (active || worktree.radius * mapScale >= 80)
|
||||
const agentCountText = translate(
|
||||
'dashboardPopout.map.agentCount',
|
||||
worktree.agents.length === 1 ? '{{count}} agent' : '{{count}} agents',
|
||||
{ count: worktree.agents.length }
|
||||
)
|
||||
return (
|
||||
<g
|
||||
className={`agent-map-worktree-label-group${visible ? ' is-visible' : ''}${active ? ' is-active' : ''}${showCount ? ' is-count-visible' : ''}`}
|
||||
transform={`translate(${worktree.x} ${worktree.y - worktree.radius}) scale(${labelScale})`}
|
||||
>
|
||||
<text className="agent-map-worktree-label" y={18}>
|
||||
{worktree.name}
|
||||
</text>
|
||||
<text className="agent-map-worktree-count" y={32}>
|
||||
{agentCountText}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})
|
||||
@@ -19,20 +19,18 @@ type AgentMapWorktreeRingNodeProps = {
|
||||
project: AgentMapProjectRing
|
||||
worktree: AgentMapWorktreeRing
|
||||
zoom: number
|
||||
labelScale: number
|
||||
mapScale: number
|
||||
selectedPaneKey: string | null
|
||||
allowAggregation: boolean
|
||||
/** Decided by the scene's declutter pass so labels never stack on each other. */
|
||||
labelVisible: boolean
|
||||
launchableAgents?: readonly TuiAgent[]
|
||||
nodeRefs: MutableRefObject<Map<string, SVGGElement>>
|
||||
onSelectAgent: (card: DashboardCard, side: 'left' | 'right') => void
|
||||
onSelectAgent: (card: DashboardCard) => void
|
||||
onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void
|
||||
onOpenWorkspaceContextMenu?: (
|
||||
event: React.MouseEvent<SVGCircleElement>,
|
||||
worktree: AgentMapWorktreeRing
|
||||
) => void
|
||||
onLabelActiveChange: (worktreeId: string, active: boolean) => void
|
||||
onAgentKeyDown: (event: React.KeyboardEvent<SVGGElement>, agent: AgentMapAgentNode) => void
|
||||
}
|
||||
|
||||
@@ -57,15 +55,15 @@ function lineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): strin
|
||||
return `M ${parent.x} ${startY} L ${parent.x} ${branchY} L ${child.x} ${branchY} L ${child.x} ${endY}`
|
||||
}
|
||||
|
||||
function panelSideFor(element: Element): 'left' | 'right' {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
return bounds.left + bounds.width / 2 <= window.innerWidth / 2 ? 'right' : 'left'
|
||||
}
|
||||
|
||||
function agentName(card: DashboardCard): string {
|
||||
return card.conversationName ?? (card.task.trim() || card.agentType)
|
||||
}
|
||||
|
||||
export function agentMapAttentionMarkerScale(mapScale: number): number {
|
||||
const inverseScale = 1 / Math.max(mapScale, 0.001)
|
||||
return Math.max(1, inverseScale ** 0.72, inverseScale * 0.5)
|
||||
}
|
||||
|
||||
function WorktreeDetails({
|
||||
project,
|
||||
worktree,
|
||||
@@ -106,28 +104,34 @@ function WorktreeDetails({
|
||||
{translate('dashboardPopout.map.runningAgents', 'Agents')}
|
||||
</h3>
|
||||
<div className="scrollbar-sleek max-h-56 space-y-0.5 overflow-y-auto">
|
||||
{worktree.agents.map((agent) => (
|
||||
<button
|
||||
key={agent.card.paneKey}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
onClick={(event) => {
|
||||
onSelectAgent(agent.card, panelSideFor(event.currentTarget))
|
||||
onDone()
|
||||
}}
|
||||
>
|
||||
<AgentIcon agent={agentTypeToIconAgent(agent.card.agentType)} size={14} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12px] font-medium">
|
||||
{agentName(agent.card)}
|
||||
{worktree.agents.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">
|
||||
{translate('dashboardPopout.map.noWorkspaceAgents', 'No agents in this workspace.')}
|
||||
</p>
|
||||
) : (
|
||||
worktree.agents.map((agent) => (
|
||||
<button
|
||||
key={agent.card.paneKey}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
onSelectAgent(agent.card)
|
||||
onDone()
|
||||
}}
|
||||
>
|
||||
<AgentIcon agent={agentTypeToIconAgent(agent.card.agentType)} size={14} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12px] font-medium">
|
||||
{agentName(agent.card)}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">
|
||||
{agentStateLabel(agent.status)} · {formatDuration(agent.durationMinutes)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">
|
||||
{agentStateLabel(agent.status)} · {formatDuration(agent.durationMinutes)}
|
||||
</span>
|
||||
</span>
|
||||
<AgentStateDot state={agent.status} size="md" />
|
||||
</button>
|
||||
))}
|
||||
<AgentStateDot state={agent.status} size="md" />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{onSpawnAgent ? (
|
||||
@@ -170,31 +174,40 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({
|
||||
project,
|
||||
worktree,
|
||||
zoom,
|
||||
labelScale,
|
||||
mapScale,
|
||||
selectedPaneKey,
|
||||
allowAggregation,
|
||||
labelVisible,
|
||||
launchableAgents,
|
||||
nodeRefs,
|
||||
onSelectAgent,
|
||||
onSpawnAgent,
|
||||
onOpenWorkspaceContextMenu,
|
||||
onLabelActiveChange,
|
||||
onAgentKeyDown
|
||||
}: AgentMapWorktreeRingNodeProps): React.JSX.Element {
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const selected = worktree.agents.some((agent) => agent.card.paneKey === selectedPaneKey)
|
||||
const aggregate = !selected && shouldAggregateAgentMapWorktree(worktree, zoom, allowAggregation)
|
||||
const agentsByPaneKey = new Map(worktree.agents.map((agent) => [agent.card.paneKey, agent]))
|
||||
const screenRadius = worktree.radius * mapScale
|
||||
const showCount = labelVisible && screenRadius >= 80
|
||||
|
||||
return (
|
||||
<Popover open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||
<g className="agent-map-worktree-group">
|
||||
<g
|
||||
className="agent-map-worktree-group"
|
||||
onPointerEnter={() => onLabelActiveChange(worktree.id, true)}
|
||||
onPointerLeave={() => onLabelActiveChange(worktree.id, false)}
|
||||
onFocus={() => onLabelActiveChange(worktree.id, true)}
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
onLabelActiveChange(worktree.id, false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<circle
|
||||
className={`agent-map-worktree-ring${selected ? ' is-selected' : ''}${detailsOpen ? ' is-open' : ''}`}
|
||||
data-agent-map-worktree=""
|
||||
data-agent-count={worktree.agents.length}
|
||||
cx={worktree.x}
|
||||
cy={worktree.y}
|
||||
r={worktree.radius}
|
||||
@@ -231,21 +244,6 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({
|
||||
}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<g
|
||||
className={`agent-map-worktree-label-group${labelVisible ? ' is-visible' : ''}${showCount ? ' is-count-visible' : ''}`}
|
||||
transform={`translate(${worktree.x} ${worktree.y - worktree.radius}) scale(${labelScale})`}
|
||||
>
|
||||
<text className="agent-map-worktree-label" y={18}>
|
||||
{worktree.name}
|
||||
</text>
|
||||
<text className="agent-map-worktree-count" y={32}>
|
||||
{translate(
|
||||
'dashboardPopout.map.agentCount',
|
||||
worktree.agents.length === 1 ? '{{count}} agent' : '{{count}} agents',
|
||||
{ count: worktree.agents.length }
|
||||
)}
|
||||
</text>
|
||||
</g>
|
||||
{aggregate ? (
|
||||
<g
|
||||
className="agent-map-aggregate-node"
|
||||
@@ -289,10 +287,14 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({
|
||||
data-agent-provider={agent.card.agentType}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selectedPaneKey === agent.card.paneKey}
|
||||
aria-label={`${agentName(agent.card)}, ${agentStateLabel(agent.status)}${agent.card.unseen ? ', unread' : ''}, ${formatDuration(agent.durationMinutes)}, ${worktree.name}, ${project.name}`}
|
||||
className={`agent-map-agent-node fleet-status-${agent.status}${selectedPaneKey === agent.card.paneKey ? ' is-selected' : ''}`}
|
||||
transform={`translate(${agent.x} ${agent.y})`}
|
||||
onClick={(event) => onSelectAgent(agent.card, panelSideFor(event.currentTarget))}
|
||||
onClick={(event) => {
|
||||
event.currentTarget.focus()
|
||||
onSelectAgent(agent.card)
|
||||
}}
|
||||
onKeyDown={(event) => onAgentKeyDown(event, agent)}
|
||||
>
|
||||
<circle className="agent-map-agent-hit" r={Math.max(10, agent.radius + 3)} />
|
||||
@@ -318,7 +320,8 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({
|
||||
data-agent-unread-marker=""
|
||||
cx={-agent.radius * Math.SQRT1_2}
|
||||
cy={-agent.radius * Math.SQRT1_2}
|
||||
r={4.5}
|
||||
r={agent.radius * 0.225 * agentMapAttentionMarkerScale(mapScale)}
|
||||
vectorEffect="none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AgentKanbanBoard, type AgentDashboardView } from './AgentKanbanBoard'
|
||||
import { useDashboardSnapshot } from './useDashboardSnapshot'
|
||||
|
||||
@@ -12,7 +13,9 @@ type DashboardPopoutRootProps = {
|
||||
*/
|
||||
export function DashboardPopoutRoot(_props: DashboardPopoutRootProps): React.JSX.Element {
|
||||
const snapshot = useDashboardSnapshot()
|
||||
const initialView: AgentDashboardView =
|
||||
const [view, setView] = useState<AgentDashboardView>(() =>
|
||||
_props.view === 'map' || _props.view === 'rings' ? 'map' : 'board'
|
||||
return <AgentKanbanBoard snapshot={snapshot} initialView={initialView} />
|
||||
)
|
||||
useEffect(() => window.api.dashboard.onViewRequested(setView), [])
|
||||
return <AgentKanbanBoard key={view} snapshot={snapshot} initialView={view} />
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { DashboardCard, DashboardCardReview } from '../../../../shared/dashboard-snapshot'
|
||||
import type {
|
||||
DashboardCard,
|
||||
DashboardCardReview,
|
||||
DashboardWorkspace
|
||||
} from '../../../../shared/dashboard-snapshot'
|
||||
|
||||
export type DashboardReviewFilter = DashboardCardReview['state'] | 'none'
|
||||
|
||||
@@ -36,6 +40,34 @@ function cardSearchText(card: DashboardCard): string {
|
||||
.toLocaleLowerCase()
|
||||
}
|
||||
|
||||
function workspaceSearchText(workspace: DashboardWorkspace): string {
|
||||
return [
|
||||
workspace.worktreeName,
|
||||
workspace.repoName,
|
||||
workspace.review ? `#${workspace.review.number}` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLocaleLowerCase()
|
||||
}
|
||||
|
||||
function workspaceMatchesFilters(
|
||||
workspace: DashboardWorkspace,
|
||||
normalizedQuery: string,
|
||||
filters: DashboardFilters
|
||||
): boolean {
|
||||
const reviewState = workspace.review?.state ?? (workspace.hasReview ? null : 'none')
|
||||
return (
|
||||
(normalizedQuery.length === 0 || workspaceSearchText(workspace).includes(normalizedQuery)) &&
|
||||
(filters.projects.length === 0 || filters.projects.includes(workspace.repoId)) &&
|
||||
(filters.workspaceStatuses.length === 0 ||
|
||||
(workspace.workspaceStatusId !== undefined &&
|
||||
filters.workspaceStatuses.includes(workspace.workspaceStatusId))) &&
|
||||
(filters.reviewStates.length === 0 ||
|
||||
(reviewState !== null && filters.reviewStates.includes(reviewState)))
|
||||
)
|
||||
}
|
||||
|
||||
export function filterDashboardCards(
|
||||
cards: DashboardCard[],
|
||||
query: string,
|
||||
@@ -56,6 +88,17 @@ export function filterDashboardCards(
|
||||
})
|
||||
}
|
||||
|
||||
export function filterDashboardWorkspaces(
|
||||
workspaces: DashboardWorkspace[],
|
||||
query: string,
|
||||
filters: DashboardFilters
|
||||
): DashboardWorkspace[] {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase()
|
||||
return workspaces.filter((workspace) =>
|
||||
workspaceMatchesFilters(workspace, normalizedQuery, filters)
|
||||
)
|
||||
}
|
||||
|
||||
export function toggleDashboardFilter<T extends string>(values: T[], value: T): T[] {
|
||||
return values.includes(value)
|
||||
? values.filter((candidate) => candidate !== value)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
|
||||
import { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata'
|
||||
|
||||
const GOLDEN_ANGLE = 2.399963229728653
|
||||
|
||||
function stableHash(value: string): number {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
}
|
||||
return hash >>> 0
|
||||
}
|
||||
|
||||
export function placeAgentMapAgents({
|
||||
worktreeId,
|
||||
cards,
|
||||
radius,
|
||||
agentRadius,
|
||||
now
|
||||
}: {
|
||||
worktreeId: string
|
||||
cards: DashboardCard[]
|
||||
radius: number
|
||||
agentRadius: number
|
||||
now: number
|
||||
}): {
|
||||
card: DashboardCard
|
||||
x: number
|
||||
y: number
|
||||
radius: number
|
||||
durationMinutes: number
|
||||
status: DashboardCard['dotState']
|
||||
}[] {
|
||||
const availableRadius = Math.max(0, radius - agentRadius - 6)
|
||||
const sorted = [...cards].sort((a, b) =>
|
||||
a.paneKey < b.paneKey ? -1 : a.paneKey > b.paneKey ? 1 : 0
|
||||
)
|
||||
const capacity = Math.ceil(Math.sqrt(Math.max(1, sorted.length))) ** 2
|
||||
const angleOffset = (stableHash(worktreeId) / 0xffffffff) * Math.PI * 2
|
||||
|
||||
return sorted.map((card, index) => {
|
||||
const orbit = sorted.length === 1 ? 0 : Math.sqrt((index + 0.5) / capacity) * availableRadius
|
||||
const angle = angleOffset + index * GOLDEN_ANGLE
|
||||
return {
|
||||
card,
|
||||
x: Math.cos(angle) * orbit,
|
||||
y: Math.sin(angle) * orbit,
|
||||
radius: agentRadius,
|
||||
durationMinutes: agentMapDurationMinutes(card, now),
|
||||
status: agentMapNodeStatus(card)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -23,7 +23,7 @@ function worktree(overrides: Partial<AgentMapWorktreeRing> = {}): AgentMapWorktr
|
||||
x: 0,
|
||||
y: 0,
|
||||
radius: 62,
|
||||
// Only the count matters to the declutter pass; the nodes never reach it.
|
||||
// Sparse placeholders keep tests that only care about label-to-label collisions concise.
|
||||
agents: Array.from({ length: total }) as AgentMapWorktreeRing['agents'],
|
||||
statusCounts: counts,
|
||||
quiet: counts.idle === total,
|
||||
@@ -80,6 +80,31 @@ describe('selectVisibleAgentMapLabels', () => {
|
||||
expect([...worktreeIds]).toEqual(['busy'])
|
||||
})
|
||||
|
||||
it('hides a workspace title that would cover an agent', () => {
|
||||
const coveringAgent = { x: 0, y: -48, radius: 20 }
|
||||
const covered = worktree({
|
||||
id: 'covered',
|
||||
agents: [coveringAgent] as AgentMapWorktreeRing['agents']
|
||||
})
|
||||
|
||||
const labels = selectVisibleAgentMapLabels(layoutOf([covered]), 1, 1)
|
||||
|
||||
expect(labels.worktreeIds.size).toBe(0)
|
||||
expect(labels.worktreeAgentSafeIds.size).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps a collision-suppressed workspace title safe to reveal away from agents', () => {
|
||||
const layout = layoutOf([
|
||||
worktree({ id: 'busy', x: 0, y: 0, statusCounts: statusCounts({ working: 3 }) }),
|
||||
worktree({ id: 'calm', name: 'beta', x: 0, y: 0, statusCounts: statusCounts({ done: 1 }) })
|
||||
])
|
||||
|
||||
const labels = selectVisibleAgentMapLabels(layout, 1, 1)
|
||||
|
||||
expect([...labels.worktreeIds]).toEqual(['busy'])
|
||||
expect([...labels.worktreeAgentSafeIds].sort()).toEqual(['busy', 'calm'])
|
||||
})
|
||||
|
||||
it('lets a blocked workspace outrank a busier neighbour for the surviving label', () => {
|
||||
const layout = layoutOf([
|
||||
worktree({ id: 'blocked', x: 0, y: 0, statusCounts: statusCounts({ blocked: 1 }) }),
|
||||
|
||||
@@ -14,6 +14,7 @@ const PROJECT_LABEL_ICON_PX = 16
|
||||
/** Local-unit breathing room so two labels never appear to touch. */
|
||||
const LABEL_GAP_X_PX = 3
|
||||
const LABEL_GAP_Y_PX = 1
|
||||
const AGENT_LABEL_CLEARANCE_PX = 3
|
||||
/** Baselines the scene renders at, relative to each label group's origin. */
|
||||
const WORKTREE_LABEL_BASELINE = 18
|
||||
const COUNT_BASELINE = 32
|
||||
@@ -37,6 +38,8 @@ type LabelGrid = Map<number, Map<number, LabelBox[]>>
|
||||
export type AgentMapVisibleLabels = {
|
||||
/** Worktree ring ids whose name can render without colliding. */
|
||||
worktreeIds: Set<string>
|
||||
/** Worktree names that hover/focus may reveal without covering an agent. */
|
||||
worktreeAgentSafeIds: Set<string>
|
||||
/** Project ids whose agent/workspace count line still has room. The count is
|
||||
* the first thing dropped: it repeats what the filter rail already says. */
|
||||
projectCountIds: Set<string>
|
||||
@@ -46,6 +49,14 @@ function textWidth(text: string, fontPx: number, ratio = GLYPH_WIDTH_RATIO): num
|
||||
return text.length * fontPx * ratio
|
||||
}
|
||||
|
||||
export function agentMapWorktreeLabelWidth(text: string): number {
|
||||
return textWidth(text, WORKTREE_LABEL_FONT_PX)
|
||||
}
|
||||
|
||||
export function agentMapCountLabelWidth(text: string): number {
|
||||
return textWidth(text, COUNT_FONT_PX)
|
||||
}
|
||||
|
||||
function boxesOverlap(a: LabelBox, b: LabelBox): boolean {
|
||||
return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom
|
||||
}
|
||||
@@ -149,6 +160,26 @@ function compareStable(a: string, b: string): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0
|
||||
}
|
||||
|
||||
function addAgentExclusionBoxes(grid: LabelGrid, layout: AgentMapLayout, mapScale: number): void {
|
||||
const clearance = AGENT_LABEL_CLEARANCE_PX / Math.max(mapScale, 0.001)
|
||||
for (const project of layout.projects) {
|
||||
for (const worktree of project.worktrees) {
|
||||
for (const agent of worktree.agents) {
|
||||
if (!agent) {
|
||||
continue
|
||||
}
|
||||
const radius = agent.radius + clearance
|
||||
addBox(grid, {
|
||||
left: agent.x - radius,
|
||||
right: agent.x + radius,
|
||||
top: agent.y - radius,
|
||||
bottom: agent.y + radius
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the labels that can render without stacking on each other. Labels draw
|
||||
* at a fixed screen size, so which ones fit depends on zoom but never on pan —
|
||||
@@ -161,7 +192,10 @@ export function selectVisibleAgentMapLabels(
|
||||
labelScale: number,
|
||||
mapScale: number
|
||||
): AgentMapVisibleLabels {
|
||||
const agentGrid: LabelGrid = new Map()
|
||||
addAgentExclusionBoxes(agentGrid, layout, mapScale)
|
||||
const grid: LabelGrid = new Map()
|
||||
addAgentExclusionBoxes(grid, layout, mapScale)
|
||||
for (const project of layout.projects) {
|
||||
const name = project.name.toUpperCase()
|
||||
addBox(
|
||||
@@ -195,6 +229,7 @@ export function selectVisibleAgentMapLabels(
|
||||
})
|
||||
|
||||
const worktreeIds = new Set<string>()
|
||||
const worktreeAgentSafeIds = new Set<string>()
|
||||
for (const worktree of candidates.slice(0, MAX_LABEL_CANDIDATES)) {
|
||||
const box = baselineBox(
|
||||
worktree.x,
|
||||
@@ -204,6 +239,10 @@ export function selectVisibleAgentMapLabels(
|
||||
WORKTREE_LABEL_FONT_PX,
|
||||
WORKTREE_LABEL_BASELINE
|
||||
)
|
||||
if (collides(agentGrid, box)) {
|
||||
continue
|
||||
}
|
||||
worktreeAgentSafeIds.add(worktree.id)
|
||||
if (collides(grid, box)) {
|
||||
continue
|
||||
}
|
||||
@@ -230,5 +269,5 @@ export function selectVisibleAgentMapLabels(
|
||||
projectCountIds.add(project.id)
|
||||
}
|
||||
|
||||
return { worktreeIds, projectCountIds }
|
||||
return { worktreeIds, worktreeAgentSafeIds, projectCountIds }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
|
||||
import type { AgentMapLayout, AgentMapStatusCounts } from './agent-map-layout'
|
||||
import { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata'
|
||||
|
||||
function emptyStatusCounts(): AgentMapStatusCounts {
|
||||
return { working: 0, blocked: 0, waiting: 0, done: 0, idle: 0 }
|
||||
}
|
||||
|
||||
export function refreshAgentMapMetadata(
|
||||
geometry: AgentMapLayout,
|
||||
cards: DashboardCard[],
|
||||
now: number
|
||||
): AgentMapLayout {
|
||||
const cardsByPaneKey = new Map(cards.map((card) => [card.paneKey, card]))
|
||||
const projects = geometry.projects.map((project) => {
|
||||
let projectName = project.name
|
||||
let agentCount = 0
|
||||
const worktrees = project.worktrees.map((worktree) => {
|
||||
let worktreeName = worktree.name
|
||||
let workspaceKind = worktree.workspaceKind
|
||||
const statusCounts = emptyStatusCounts()
|
||||
const agents = worktree.agents.flatMap((agent) => {
|
||||
const card = cardsByPaneKey.get(agent.card.paneKey)
|
||||
if (!card) {
|
||||
return []
|
||||
}
|
||||
projectName = card.repoName
|
||||
worktreeName = card.worktreeName
|
||||
workspaceKind = card.workspaceKind ?? 'worktree'
|
||||
agentCount += 1
|
||||
statusCounts[agentMapNodeStatus(card)] += 1
|
||||
return [
|
||||
{
|
||||
...agent,
|
||||
card,
|
||||
durationMinutes: agentMapDurationMinutes(card, now),
|
||||
status: agentMapNodeStatus(card)
|
||||
}
|
||||
]
|
||||
})
|
||||
return {
|
||||
...worktree,
|
||||
name: worktreeName,
|
||||
workspaceKind,
|
||||
agents,
|
||||
statusCounts,
|
||||
quiet: statusCounts.idle === agents.length
|
||||
}
|
||||
})
|
||||
return { ...project, name: projectName, worktrees, agentCount }
|
||||
})
|
||||
return { ...geometry, projects }
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
|
||||
import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot'
|
||||
import {
|
||||
deriveAgentMapLayout,
|
||||
AGENT_MAP_AGENT_RADIUS,
|
||||
AGENT_MAP_RING_HEADER_HEIGHT,
|
||||
AGENT_MAP_WORKTREE_GAP,
|
||||
agentMapDurationMinutes,
|
||||
agentMapNodeStatus,
|
||||
@@ -46,7 +47,39 @@ function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
|
||||
}
|
||||
}
|
||||
|
||||
function workspace(overrides: Partial<DashboardWorkspace> = {}): DashboardWorkspace {
|
||||
return {
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'empty-worktree',
|
||||
repoName: 'Orca',
|
||||
worktreeName: 'Empty worktree',
|
||||
hostKind: 'local',
|
||||
executionHostId: 'local',
|
||||
workspaceKind: 'worktree',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('agent map layout', () => {
|
||||
it('lays out agentless workspaces and preserves their workspace lineage', () => {
|
||||
const layout = deriveAgentMapLayout([], NOW, [
|
||||
workspace({ worktreeId: 'parent', worktreeName: 'Parent' }),
|
||||
workspace({
|
||||
worktreeId: 'child',
|
||||
worktreeName: 'Child',
|
||||
parentWorktreeId: 'parent'
|
||||
})
|
||||
])
|
||||
const project = layout.projects[0]
|
||||
const parent = project.worktrees.find((item) => item.worktreeId === 'parent')!
|
||||
const child = project.worktrees.find((item) => item.worktreeId === 'child')!
|
||||
|
||||
expect(project.agentCount).toBe(0)
|
||||
expect(parent.agents).toEqual([])
|
||||
expect(child.parentId).toBe(parent.id)
|
||||
expect(child.y).toBeGreaterThan(parent.y)
|
||||
})
|
||||
|
||||
it('derives project containment, workspace containment, and every agent node', () => {
|
||||
const cards = [
|
||||
card({ paneKey: 'a', repoId: 'repo-a', worktreeId: 'wt-a' }),
|
||||
@@ -109,6 +142,35 @@ describe('agent map layout', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reserves project and workspace header bands above dense ring contents', () => {
|
||||
const layout = deriveAgentMapLayout(
|
||||
Array.from({ length: 24 }, (_unused, index) =>
|
||||
card({ paneKey: `dense-${index.toString().padStart(2, '0')}` })
|
||||
),
|
||||
NOW
|
||||
)
|
||||
const project = layout.projects[0]
|
||||
const worktree = project.worktrees[0]
|
||||
const projectTop = project.y - project.radius
|
||||
const worktreeTop = worktree.y - worktree.radius
|
||||
const agentTop = Math.min(...worktree.agents.map((agent) => agent.y - agent.radius))
|
||||
|
||||
expect(worktreeTop - projectTop).toBeGreaterThanOrEqual(AGENT_MAP_RING_HEADER_HEIGHT)
|
||||
expect(agentTop - worktreeTop).toBeGreaterThanOrEqual(AGENT_MAP_RING_HEADER_HEIGHT)
|
||||
})
|
||||
|
||||
it('keeps sparse project and workspace rings compact around their header bands', () => {
|
||||
const single = deriveAgentMapLayout([card()], NOW).projects[0]
|
||||
const four = deriveAgentMapLayout(
|
||||
Array.from({ length: 4 }, (_unused, index) => card({ paneKey: `agent-${index}` })),
|
||||
NOW
|
||||
).projects[0]
|
||||
|
||||
expect(single.worktrees[0].radius).toBeLessThanOrEqual(72)
|
||||
expect(single.radius).toBeLessThanOrEqual(104)
|
||||
expect(four.worktrees[0].radius).toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['single', [card()]],
|
||||
[
|
||||
@@ -131,7 +193,7 @@ describe('agent map layout', () => {
|
||||
expect((top + bottom) / 2).toBeCloseTo(layout.height / 2)
|
||||
})
|
||||
|
||||
it('places orchestrated descendants beneath their direct parent inside the workspace', () => {
|
||||
it('places spawned descendants beneath their direct parent inside the workspace', () => {
|
||||
const layout = deriveAgentMapLayout(
|
||||
[
|
||||
card({ paneKey: 'parent' }),
|
||||
@@ -158,7 +220,7 @@ describe('agent map layout', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('packs high-fanout orchestrated children into a compact deterministic cluster', () => {
|
||||
it('packs high-fanout spawned children into a compact deterministic cluster', () => {
|
||||
const cards = [
|
||||
card({ paneKey: 'parent' }),
|
||||
...Array.from({ length: 29 }, (_, index) =>
|
||||
@@ -233,6 +295,57 @@ describe('agent map layout', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('clusters cross-worktree spawned agents without inventing workspace lineage', () => {
|
||||
const layout = deriveAgentMapLayout(
|
||||
[
|
||||
card({ paneKey: 'parent', worktreeId: 'parent-worktree' }),
|
||||
card({
|
||||
paneKey: 'child',
|
||||
worktreeId: 'child-worktree',
|
||||
parentPaneKey: 'parent'
|
||||
}),
|
||||
card({ paneKey: 'unrelated', worktreeId: 'unrelated-worktree' })
|
||||
],
|
||||
NOW
|
||||
)
|
||||
const worktrees = new Map(
|
||||
layout.projects[0].worktrees.map((worktree) => [worktree.worktreeId, worktree])
|
||||
)
|
||||
const parent = worktrees.get('parent-worktree')!
|
||||
const child = worktrees.get('child-worktree')!
|
||||
|
||||
expect(child.clusterParentId).toBe(parent.id)
|
||||
expect(child.parentId).toBeUndefined()
|
||||
expect(child.y).toBeGreaterThan(parent.y)
|
||||
expect(Math.hypot(child.x - parent.x, child.y - parent.y)).toBeLessThan(
|
||||
child.radius + parent.radius + 100
|
||||
)
|
||||
})
|
||||
|
||||
it('clusters spawned agents whose parent is in another project', () => {
|
||||
const layout = deriveAgentMapLayout(
|
||||
[
|
||||
card({ paneKey: 'parent', repoId: 'repo-parent', worktreeId: 'parent-worktree' }),
|
||||
card({
|
||||
paneKey: 'child',
|
||||
repoId: 'repo-child',
|
||||
worktreeId: 'child-worktree',
|
||||
parentPaneKey: 'parent'
|
||||
}),
|
||||
card({ paneKey: 'unrelated', repoId: 'repo-unrelated', worktreeId: 'unrelated-worktree' })
|
||||
],
|
||||
NOW
|
||||
)
|
||||
const projects = new Map(layout.projects.map((project) => [project.id, project]))
|
||||
const parent = projects.get('repo-parent')!
|
||||
const child = projects.get('repo-child')!
|
||||
|
||||
expect(child.y).toBeGreaterThan(parent.y)
|
||||
expect(Math.hypot(child.x - parent.x, child.y - parent.y)).toBeLessThan(
|
||||
child.radius + parent.radius + 100
|
||||
)
|
||||
})
|
||||
|
||||
it('repacks cached geometry when a worktree parent changes', () => {
|
||||
const cards = [
|
||||
card({ paneKey: 'parent-a', worktreeId: 'parent-a' }),
|
||||
@@ -254,7 +367,7 @@ describe('agent map layout', () => {
|
||||
expect(updated.cache.packingGeneration).toBe(2)
|
||||
})
|
||||
|
||||
it('repacks cached geometry when an orchestration parent changes', () => {
|
||||
it('repacks cached geometry when a spawn parent changes', () => {
|
||||
const cards = [
|
||||
card({ paneKey: 'parent-a' }),
|
||||
card({ paneKey: 'parent-b' }),
|
||||
@@ -437,7 +550,7 @@ describe('agent map layout', () => {
|
||||
expect(minimumDistance).toBeGreaterThanOrEqual(AGENT_MAP_AGENT_RADIUS * 2)
|
||||
})
|
||||
|
||||
it('keeps deeply nested orchestration finite without recursive stack growth', () => {
|
||||
it('keeps deeply nested spawn lineage finite without recursive stack growth', () => {
|
||||
const layout = deriveAgentMapLayout(
|
||||
Array.from({ length: 5_000 }, (_, index) =>
|
||||
card({
|
||||
@@ -461,7 +574,7 @@ describe('agent map layout', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('wraps very large orchestration fanout without overlap', () => {
|
||||
it('wraps very large spawn fanout without overlap', () => {
|
||||
const layout = deriveAgentMapLayout(
|
||||
[
|
||||
card({ paneKey: 'parent' }),
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type * as DashboardSnapshotTypes from '../../../../shared/dashboard-snapshot'
|
||||
import { placeAgentMapAgents } from './agent-map-agent-placement'
|
||||
import { layoutAgentMapLineage } from './agent-map-lineage-layout'
|
||||
import { refreshAgentMapMetadata } from './agent-map-layout-metadata'
|
||||
import { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata'
|
||||
import { placeAgentMapProjects } from './agent-map-project-placement'
|
||||
import { selectAgentMapSpawnParentContainer } from './agent-map-spawn-clustering'
|
||||
import {
|
||||
agentMapCardTopologyIdentity,
|
||||
agentMapWorkspaceIdentity,
|
||||
agentMapWorkspaceTopologyIdentity,
|
||||
agentMapWorktreeIdentity,
|
||||
agentMapWorktreeIdentityFromParts
|
||||
} from './agent-map-workspace-identity'
|
||||
@@ -10,17 +16,18 @@ import { layoutAgentMapWorktreeLineage } from './agent-map-worktree-lineage-layo
|
||||
|
||||
type DashboardCard = DashboardSnapshotTypes.DashboardCard
|
||||
type DashboardCardDotState = DashboardSnapshotTypes.DashboardCardDotState
|
||||
type DashboardWorkspace = DashboardSnapshotTypes.DashboardWorkspace
|
||||
|
||||
export { AGENT_MAP_WORKTREE_GAP } from './agent-map-worktree-packing'
|
||||
export { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata'
|
||||
|
||||
export const AGENT_MAP_AGENT_RADIUS = 20
|
||||
export const AGENT_MAP_AGGREGATE_ZOOM = 1.15
|
||||
export const AGENT_MAP_RING_HEADER_HEIGHT = 40
|
||||
|
||||
const PROJECT_GAP = 32
|
||||
const PROJECT_PADDING = 16
|
||||
const PROJECT_PADDING = 12
|
||||
const WORLD_MARGIN = 32
|
||||
const GOLDEN_ANGLE = 2.399963229728653
|
||||
const RING_CONTENT_OFFSET = AGENT_MAP_RING_HEADER_HEIGHT / 2
|
||||
|
||||
export type AgentMapStatusCounts = Record<DashboardCardDotState, number>
|
||||
|
||||
@@ -36,6 +43,8 @@ export type AgentMapAgentNode = {
|
||||
export type AgentMapWorktreeRing = {
|
||||
id: string
|
||||
parentId?: string
|
||||
/** Layout-only parent chosen from agent spawn edges; does not imply workspace lineage. */
|
||||
clusterParentId?: string
|
||||
worktreeId: string
|
||||
executionHostId: DashboardCard['executionHostId']
|
||||
name: string
|
||||
@@ -73,6 +82,9 @@ export type AgentMapLayoutCache = {
|
||||
|
||||
type LocalWorktree = Omit<AgentMapWorktreeRing, 'x' | 'y'> & { x: number; y: number }
|
||||
type LocalProject = Omit<AgentMapProjectRing, 'x' | 'y' | 'worktrees'> & {
|
||||
x: number
|
||||
y: number
|
||||
clusterParentId?: string
|
||||
worktrees: LocalWorktree[]
|
||||
}
|
||||
|
||||
@@ -80,17 +92,16 @@ function compareStable(a: string, b: string): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0
|
||||
}
|
||||
|
||||
function stableHash(value: string): number {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
}
|
||||
return hash >>> 0
|
||||
}
|
||||
|
||||
export function agentMapTopologyKey(cards: DashboardCard[]): string {
|
||||
return cards.map(agentMapCardTopologyIdentity).sort(compareStable).join('|')
|
||||
export function agentMapTopologyKey(
|
||||
cards: DashboardCard[],
|
||||
workspaces: DashboardWorkspace[] = []
|
||||
): string {
|
||||
return [
|
||||
...cards.map((card) => `a:${agentMapCardTopologyIdentity(card)}`),
|
||||
...workspaces.map((workspace) => `w:${agentMapWorkspaceTopologyIdentity(workspace)}`)
|
||||
]
|
||||
.sort(compareStable)
|
||||
.join('|')
|
||||
}
|
||||
|
||||
export function shouldAggregateAgentMapWorktree(
|
||||
@@ -112,56 +123,39 @@ function emptyStatusCounts(): AgentMapStatusCounts {
|
||||
|
||||
function worktreeRadius(agentCount: number): number {
|
||||
return Math.max(
|
||||
62,
|
||||
34 + Math.ceil(Math.sqrt(Math.max(1, agentCount))) * (AGENT_MAP_AGENT_RADIUS + 8)
|
||||
52,
|
||||
24 + Math.ceil(Math.sqrt(Math.max(1, agentCount))) * (AGENT_MAP_AGENT_RADIUS + 8)
|
||||
)
|
||||
}
|
||||
|
||||
function placeAgents(
|
||||
worktreeId: string,
|
||||
function buildLocalWorktree(
|
||||
id: string,
|
||||
cards: DashboardCard[],
|
||||
radius: number,
|
||||
now: number
|
||||
): AgentMapAgentNode[] {
|
||||
const availableRadius = Math.max(0, radius - AGENT_MAP_AGENT_RADIUS - 10)
|
||||
const sorted = [...cards].sort((a, b) => compareStable(a.paneKey, b.paneKey))
|
||||
const capacity = Math.ceil(Math.sqrt(Math.max(1, sorted.length))) ** 2
|
||||
const angleOffset = (stableHash(worktreeId) / 0xffffffff) * Math.PI * 2
|
||||
|
||||
return sorted.map((card, index) => {
|
||||
const orbit = sorted.length === 1 ? 0 : Math.sqrt((index + 0.5) / capacity) * availableRadius
|
||||
const angle = angleOffset + index * GOLDEN_ANGLE
|
||||
return {
|
||||
card,
|
||||
x: Math.cos(angle) * orbit,
|
||||
y: Math.sin(angle) * orbit,
|
||||
radius: AGENT_MAP_AGENT_RADIUS,
|
||||
durationMinutes: agentMapDurationMinutes(card, now),
|
||||
status: agentMapNodeStatus(card)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildLocalWorktree(id: string, cards: DashboardCard[], now: number): LocalWorktree {
|
||||
now: number,
|
||||
workspace?: DashboardWorkspace
|
||||
): LocalWorktree {
|
||||
const lineageLayout = layoutAgentMapLineage(cards, AGENT_MAP_AGENT_RADIUS)
|
||||
const radius = lineageLayout?.radius ?? worktreeRadius(cards.length)
|
||||
const contentRadius = lineageLayout?.radius ?? worktreeRadius(cards.length)
|
||||
const radius = contentRadius + RING_CONTENT_OFFSET
|
||||
const statusCounts = emptyStatusCounts()
|
||||
for (const card of cards) {
|
||||
statusCounts[agentMapNodeStatus(card)] += 1
|
||||
}
|
||||
const executionHostId = workspace?.executionHostId ?? cards[0]?.executionHostId
|
||||
const parentWorktreeId = workspace?.parentWorktreeId ?? cards[0]?.parentWorktreeId
|
||||
return {
|
||||
id,
|
||||
parentId: cards[0]?.parentWorktreeId
|
||||
? agentMapWorktreeIdentityFromParts(cards[0].parentWorktreeId, cards[0].executionHostId)
|
||||
parentId: parentWorktreeId
|
||||
? agentMapWorktreeIdentityFromParts(parentWorktreeId, executionHostId)
|
||||
: undefined,
|
||||
worktreeId: cards[0]?.worktreeId ?? id,
|
||||
executionHostId: cards[0]?.executionHostId,
|
||||
name: cards[0]?.worktreeName ?? id,
|
||||
workspaceKind: cards[0]?.workspaceKind ?? 'worktree',
|
||||
worktreeId: workspace?.worktreeId ?? cards[0]?.worktreeId ?? id,
|
||||
executionHostId,
|
||||
name: workspace?.worktreeName ?? cards[0]?.worktreeName ?? id,
|
||||
workspaceKind: workspace?.workspaceKind ?? cards[0]?.workspaceKind ?? 'worktree',
|
||||
x: 0,
|
||||
y: 0,
|
||||
radius,
|
||||
agents:
|
||||
agents: (
|
||||
lineageLayout?.agents.map(({ card, x, y }) => ({
|
||||
card,
|
||||
x,
|
||||
@@ -169,13 +163,27 @@ function buildLocalWorktree(id: string, cards: DashboardCard[], now: number): Lo
|
||||
radius: AGENT_MAP_AGENT_RADIUS,
|
||||
durationMinutes: agentMapDurationMinutes(card, now),
|
||||
status: agentMapNodeStatus(card)
|
||||
})) ?? placeAgents(id, cards, radius, now),
|
||||
})) ??
|
||||
placeAgentMapAgents({
|
||||
worktreeId: id,
|
||||
cards,
|
||||
radius: contentRadius,
|
||||
agentRadius: AGENT_MAP_AGENT_RADIUS,
|
||||
now
|
||||
})
|
||||
).map((agent) => ({ ...agent, y: agent.y + RING_CONTENT_OFFSET })),
|
||||
statusCounts,
|
||||
quiet: statusCounts.idle === cards.length
|
||||
}
|
||||
}
|
||||
|
||||
function buildLocalProject(id: string, cards: DashboardCard[], now: number): LocalProject {
|
||||
function buildLocalProject(
|
||||
id: string,
|
||||
cards: DashboardCard[],
|
||||
workspaces: DashboardWorkspace[],
|
||||
cardsByPaneKey: ReadonlyMap<string, DashboardCard>,
|
||||
now: number
|
||||
): LocalProject {
|
||||
const byWorktree = new Map<string, DashboardCard[]>()
|
||||
for (const card of cards) {
|
||||
const identity = agentMapWorktreeIdentity(card)
|
||||
@@ -186,128 +194,106 @@ function buildLocalProject(id: string, cards: DashboardCard[], now: number): Loc
|
||||
byWorktree.set(identity, [card])
|
||||
}
|
||||
}
|
||||
const worktrees = layoutAgentMapWorktreeLineage(
|
||||
const workspacesById = new Map(
|
||||
workspaces.map((workspace) => [agentMapWorkspaceIdentity(workspace), workspace])
|
||||
)
|
||||
for (const workspaceId of workspacesById.keys()) {
|
||||
if (!byWorktree.has(workspaceId)) {
|
||||
byWorktree.set(workspaceId, [])
|
||||
}
|
||||
}
|
||||
const positionedWorktrees = layoutAgentMapWorktreeLineage(
|
||||
[...byWorktree.entries()]
|
||||
.sort(([a], [b]) => compareStable(a, b))
|
||||
.map(([worktreeId, worktreeCards]) => buildLocalWorktree(worktreeId, worktreeCards, now))
|
||||
.map(([worktreeId, worktreeCards]) => ({
|
||||
...buildLocalWorktree(worktreeId, worktreeCards, now, workspacesById.get(worktreeId)),
|
||||
clusterParentId: selectAgentMapSpawnParentContainer(
|
||||
worktreeCards,
|
||||
cardsByPaneKey,
|
||||
agentMapWorktreeIdentity
|
||||
)
|
||||
}))
|
||||
)
|
||||
const radius = Math.max(
|
||||
96,
|
||||
...worktrees.map(
|
||||
const contentRadius = Math.max(
|
||||
84,
|
||||
...positionedWorktrees.map(
|
||||
(worktree) => Math.hypot(worktree.x, worktree.y) + worktree.radius + PROJECT_PADDING
|
||||
)
|
||||
)
|
||||
const worktrees = positionedWorktrees.map((worktree) => ({
|
||||
...worktree,
|
||||
y: worktree.y + RING_CONTENT_OFFSET
|
||||
}))
|
||||
return {
|
||||
id,
|
||||
name: cards[0]?.repoName ?? id,
|
||||
radius,
|
||||
name: cards[0]?.repoName ?? workspaces[0]?.repoName ?? id,
|
||||
x: 0,
|
||||
y: 0,
|
||||
clusterParentId: selectAgentMapSpawnParentContainer(
|
||||
cards,
|
||||
cardsByPaneKey,
|
||||
(card) => card.repoId
|
||||
),
|
||||
radius: contentRadius + RING_CONTENT_OFFSET,
|
||||
worktrees,
|
||||
agentCount: cards.length
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveAgentMapLayout(cards: DashboardCard[], now: number): AgentMapLayout {
|
||||
const topologyKey = agentMapTopologyKey(cards)
|
||||
if (cards.length === 0) {
|
||||
export function deriveAgentMapLayout(
|
||||
cards: DashboardCard[],
|
||||
now: number,
|
||||
workspaces: DashboardWorkspace[] = []
|
||||
): AgentMapLayout {
|
||||
const topologyKey = agentMapTopologyKey(cards, workspaces)
|
||||
if (cards.length === 0 && workspaces.length === 0) {
|
||||
return { projects: [], width: 900, height: 560, topologyKey }
|
||||
}
|
||||
const byProject = new Map<string, DashboardCard[]>()
|
||||
const byProject = new Map<string, { cards: DashboardCard[]; workspaces: DashboardWorkspace[] }>()
|
||||
for (const card of cards) {
|
||||
const current = byProject.get(card.repoId)
|
||||
if (current) {
|
||||
current.push(card)
|
||||
} else {
|
||||
byProject.set(card.repoId, [card])
|
||||
}
|
||||
const current = byProject.get(card.repoId) ?? { cards: [], workspaces: [] }
|
||||
current.cards.push(card)
|
||||
byProject.set(card.repoId, current)
|
||||
}
|
||||
for (const workspace of workspaces) {
|
||||
const current = byProject.get(workspace.repoId) ?? { cards: [], workspaces: [] }
|
||||
current.workspaces.push(workspace)
|
||||
byProject.set(workspace.repoId, current)
|
||||
}
|
||||
const cardsByPaneKey = new Map(cards.map((card) => [card.paneKey, card]))
|
||||
const localProjects = [...byProject.entries()]
|
||||
.sort(([a], [b]) => compareStable(a, b))
|
||||
.map(([projectId, projectCards]) => buildLocalProject(projectId, projectCards, now))
|
||||
const maxProjectRadius = Math.max(...localProjects.map((project) => project.radius))
|
||||
const projectSpanWidth = localProjects.reduce((sum, project) => sum + project.radius * 2, 0)
|
||||
const naturalWidth =
|
||||
projectSpanWidth + PROJECT_GAP * (localProjects.length - 1) + WORLD_MARGIN * 2
|
||||
const naturalHeight = maxProjectRadius * 2 + WORLD_MARGIN * 2
|
||||
const width = Math.max(900, naturalWidth)
|
||||
const height = Math.max(560, naturalHeight)
|
||||
const centerY = WORLD_MARGIN + maxProjectRadius + (height - naturalHeight) / 2
|
||||
let cursorX = WORLD_MARGIN + (width - naturalWidth) / 2
|
||||
const projects = localProjects.map((project): AgentMapProjectRing => {
|
||||
const centerX = cursorX + project.radius
|
||||
cursorX += project.radius * 2 + PROJECT_GAP
|
||||
.map(([projectId, project]) =>
|
||||
buildLocalProject(projectId, project.cards, project.workspaces, cardsByPaneKey, now)
|
||||
)
|
||||
const framed = placeAgentMapProjects(localProjects, 900, 560, WORLD_MARGIN)
|
||||
const projects = framed.projects.map((project): AgentMapProjectRing => {
|
||||
return {
|
||||
...project,
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
worktrees: project.worktrees.map((worktree) => ({
|
||||
...worktree,
|
||||
x: centerX + worktree.x,
|
||||
y: centerY + worktree.y,
|
||||
x: project.x + worktree.x,
|
||||
y: project.y + worktree.y,
|
||||
agents: worktree.agents.map((agent) => ({
|
||||
...agent,
|
||||
x: centerX + worktree.x + agent.x,
|
||||
y: centerY + worktree.y + agent.y
|
||||
x: project.x + worktree.x + agent.x,
|
||||
y: project.y + worktree.y + agent.y
|
||||
}))
|
||||
}))
|
||||
}
|
||||
})
|
||||
return { projects, width, height, topologyKey }
|
||||
}
|
||||
|
||||
function refreshAgentMapMetadata(
|
||||
geometry: AgentMapLayout,
|
||||
cards: DashboardCard[],
|
||||
now: number
|
||||
): AgentMapLayout {
|
||||
const cardsByPaneKey = new Map(cards.map((card) => [card.paneKey, card]))
|
||||
const projects = geometry.projects.map((project) => {
|
||||
let projectName = project.name
|
||||
let agentCount = 0
|
||||
const worktrees = project.worktrees.map((worktree) => {
|
||||
let worktreeName = worktree.name
|
||||
let workspaceKind = worktree.workspaceKind
|
||||
const statusCounts = emptyStatusCounts()
|
||||
const agents = worktree.agents.flatMap((agent) => {
|
||||
const card = cardsByPaneKey.get(agent.card.paneKey)
|
||||
if (!card) {
|
||||
return []
|
||||
}
|
||||
projectName = card.repoName
|
||||
worktreeName = card.worktreeName
|
||||
workspaceKind = card.workspaceKind ?? 'worktree'
|
||||
agentCount += 1
|
||||
statusCounts[agentMapNodeStatus(card)] += 1
|
||||
return [
|
||||
{
|
||||
...agent,
|
||||
card,
|
||||
durationMinutes: agentMapDurationMinutes(card, now),
|
||||
status: agentMapNodeStatus(card)
|
||||
}
|
||||
]
|
||||
})
|
||||
return {
|
||||
...worktree,
|
||||
name: worktreeName,
|
||||
workspaceKind,
|
||||
agents,
|
||||
statusCounts,
|
||||
quiet: statusCounts.idle === agents.length
|
||||
}
|
||||
})
|
||||
return { ...project, name: projectName, worktrees, agentCount }
|
||||
})
|
||||
return { ...geometry, projects }
|
||||
return { projects, width: framed.width, height: framed.height, topologyKey }
|
||||
}
|
||||
|
||||
export function updateAgentMapLayout(
|
||||
cache: AgentMapLayoutCache | null,
|
||||
cards: DashboardCard[],
|
||||
now: number
|
||||
now: number,
|
||||
workspaces: DashboardWorkspace[] = []
|
||||
): { cache: AgentMapLayoutCache; layout: AgentMapLayout } {
|
||||
const topologyKey = agentMapTopologyKey(cards)
|
||||
const topologyKey = agentMapTopologyKey(cards, workspaces)
|
||||
if (!cache || cache.topologyKey !== topologyKey) {
|
||||
const geometry = deriveAgentMapLayout(cards, now)
|
||||
const geometry = deriveAgentMapLayout(cards, now, workspaces)
|
||||
return {
|
||||
cache: {
|
||||
topologyKey,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
|
||||
import { packAgentMapWorktrees } from './agent-map-worktree-packing'
|
||||
|
||||
const HORIZONTAL_GAP = 58
|
||||
const VERTICAL_GAP = 62
|
||||
const FAMILY_PADDING = 12
|
||||
const WORKTREE_PADDING = 10
|
||||
const HORIZONTAL_GAP = 54
|
||||
const VERTICAL_GAP = 58
|
||||
const FAMILY_PADDING = 8
|
||||
const WORKTREE_PADDING = 6
|
||||
const COMPACT_FANOUT_THRESHOLD = 12
|
||||
const MAX_EXACT_LINEAGE_AGENTS = 256
|
||||
|
||||
@@ -165,7 +165,7 @@ function layoutBoundedLineage(
|
||||
}
|
||||
const family = encloseFamily(sorted[0].paneKey, agents, nodeRadius)
|
||||
family.agents.sort((a, b) => compareStable(a.card.paneKey, b.card.paneKey))
|
||||
return { agents: family.agents, radius: Math.max(62, family.radius + WORKTREE_PADDING) }
|
||||
return { agents: family.agents, radius: Math.max(52, family.radius + WORKTREE_PADDING) }
|
||||
}
|
||||
|
||||
export function layoutAgentMapLineage(
|
||||
@@ -217,7 +217,7 @@ export function layoutAgentMapLineage(
|
||||
)
|
||||
.sort((a, b) => compareStable(a.card.paneKey, b.card.paneKey)),
|
||||
radius: Math.max(
|
||||
62,
|
||||
52,
|
||||
...packed.map((family) => Math.hypot(family.x, family.y) + family.radius + WORKTREE_PADDING)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const AGENT_MAP_WORKTREE_GAP = 10
|
||||
export const AGENT_MAP_WORKTREE_GAP = 8
|
||||
export const AGENT_MAP_PACKING_SCORE_TOLERANCE = 0.001
|
||||
|
||||
const PACKING_GRID_SIZE = 128
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { layoutAgentMapWorktreeLineage } from './agent-map-worktree-lineage-layout'
|
||||
|
||||
const PROJECT_GAP = 32
|
||||
|
||||
type ProjectCircle = {
|
||||
id: string
|
||||
x: number
|
||||
y: number
|
||||
radius: number
|
||||
clusterParentId?: string
|
||||
}
|
||||
|
||||
function placeUnlinkedProjects<T extends ProjectCircle>(projects: T[]): T[] {
|
||||
let cursorX = 0
|
||||
return projects.map((project) => {
|
||||
const positioned = { ...project, x: cursorX + project.radius, y: 0 }
|
||||
cursorX += project.radius * 2 + PROJECT_GAP
|
||||
return positioned
|
||||
})
|
||||
}
|
||||
|
||||
export function placeAgentMapProjects<T extends ProjectCircle>(
|
||||
projects: T[],
|
||||
minimumWidth: number,
|
||||
minimumHeight: number,
|
||||
worldMargin: number
|
||||
): { projects: T[]; width: number; height: number } {
|
||||
const positioned = projects.some((project) => project.clusterParentId)
|
||||
? layoutAgentMapWorktreeLineage(projects)
|
||||
: placeUnlinkedProjects(projects)
|
||||
const left = Math.min(...positioned.map((project) => project.x - project.radius))
|
||||
const right = Math.max(...positioned.map((project) => project.x + project.radius))
|
||||
const top = Math.min(...positioned.map((project) => project.y - project.radius))
|
||||
const bottom = Math.max(...positioned.map((project) => project.y + project.radius))
|
||||
const naturalWidth = right - left + worldMargin * 2
|
||||
const naturalHeight = bottom - top + worldMargin * 2
|
||||
const width = Math.max(minimumWidth, naturalWidth)
|
||||
const height = Math.max(minimumHeight, naturalHeight)
|
||||
const offsetX = worldMargin - left + (width - naturalWidth) / 2
|
||||
const offsetY = worldMargin - top + (height - naturalHeight) / 2
|
||||
return {
|
||||
projects: positioned.map((project) => ({
|
||||
...project,
|
||||
x: project.x + offsetX,
|
||||
y: project.y + offsetY
|
||||
})),
|
||||
width,
|
||||
height
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
|
||||
|
||||
function compareStable(a: string, b: string): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0
|
||||
}
|
||||
|
||||
export function selectAgentMapSpawnParentContainer(
|
||||
cards: readonly DashboardCard[],
|
||||
cardsByPaneKey: ReadonlyMap<string, DashboardCard>,
|
||||
containerIdentity: (card: DashboardCard) => string
|
||||
): string | undefined {
|
||||
const ownContainerId = cards[0] ? containerIdentity(cards[0]) : undefined
|
||||
const linkCounts = new Map<string, number>()
|
||||
for (const card of cards) {
|
||||
const parent = card.parentPaneKey ? cardsByPaneKey.get(card.parentPaneKey) : undefined
|
||||
const parentContainerId = parent ? containerIdentity(parent) : undefined
|
||||
if (!parentContainerId || parentContainerId === ownContainerId) {
|
||||
continue
|
||||
}
|
||||
linkCounts.set(parentContainerId, (linkCounts.get(parentContainerId) ?? 0) + 1)
|
||||
}
|
||||
return [...linkCounts]
|
||||
.sort(([leftId, leftCount], [rightId, rightCount]) =>
|
||||
rightCount !== leftCount ? rightCount - leftCount : compareStable(leftId, rightId)
|
||||
)
|
||||
.at(0)?.[0]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export type AgentMapViewport = {
|
||||
center: { x: number; y: number }
|
||||
zoom: number
|
||||
}
|
||||
|
||||
type ViewportTransitionOptions = {
|
||||
from: AgentMapViewport
|
||||
to: AgentMapViewport
|
||||
durationMs: number
|
||||
onFrame: (viewport: AgentMapViewport) => void
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
function interpolate(from: number, to: number, progress: number): number {
|
||||
return from + (to - from) * progress
|
||||
}
|
||||
|
||||
export function startAgentMapViewportTransition({
|
||||
from,
|
||||
to,
|
||||
durationMs,
|
||||
onFrame,
|
||||
onComplete
|
||||
}: ViewportTransitionOptions): () => void {
|
||||
let frameId: number | null = null
|
||||
let startedAt: number | null = null
|
||||
let cancelled = false
|
||||
const tick = (now: number): void => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
startedAt ??= now
|
||||
const progress = Math.min(1, (now - startedAt) / durationMs)
|
||||
const eased = 1 - (1 - progress) ** 3
|
||||
onFrame({
|
||||
center: {
|
||||
x: interpolate(from.center.x, to.center.x, eased),
|
||||
y: interpolate(from.center.y, to.center.y, eased)
|
||||
},
|
||||
zoom: interpolate(from.zoom, to.zoom, eased)
|
||||
})
|
||||
if (progress < 1) {
|
||||
frameId = requestAnimationFrame(tick)
|
||||
} else {
|
||||
frameId = null
|
||||
onComplete?.()
|
||||
}
|
||||
}
|
||||
frameId = requestAnimationFrame(tick)
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (frameId !== null) {
|
||||
cancelAnimationFrame(frameId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
|
||||
import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot'
|
||||
|
||||
export function agentMapCardTopologyIdentity(card: DashboardCard): string {
|
||||
const parentPaneKey = card.parentPaneKey ?? ''
|
||||
@@ -7,6 +7,11 @@ export function agentMapCardTopologyIdentity(card: DashboardCard): string {
|
||||
return `${card.repoId.length}:${card.repoId}${card.worktreeId.length}:${card.worktreeId}${executionHostId.length}:${executionHostId}${card.paneKey.length}:${card.paneKey}${parentPaneKey.length}:${parentPaneKey}${parentWorktreeId.length}:${parentWorktreeId}`
|
||||
}
|
||||
|
||||
export function agentMapWorkspaceTopologyIdentity(workspace: DashboardWorkspace): string {
|
||||
const parentWorktreeId = workspace.parentWorktreeId ?? ''
|
||||
return `${workspace.repoId.length}:${workspace.repoId}${workspace.worktreeId.length}:${workspace.worktreeId}${workspace.executionHostId.length}:${workspace.executionHostId}${parentWorktreeId.length}:${parentWorktreeId}`
|
||||
}
|
||||
|
||||
export function agentMapWorktreeIdentityFromParts(
|
||||
worktreeId: string,
|
||||
executionHostId: DashboardCard['executionHostId']
|
||||
@@ -18,3 +23,7 @@ export function agentMapWorktreeIdentityFromParts(
|
||||
export function agentMapWorktreeIdentity(card: DashboardCard): string {
|
||||
return agentMapWorktreeIdentityFromParts(card.worktreeId, card.executionHostId)
|
||||
}
|
||||
|
||||
export function agentMapWorkspaceIdentity(workspace: DashboardWorkspace): string {
|
||||
return agentMapWorktreeIdentityFromParts(workspace.worktreeId, workspace.executionHostId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot'
|
||||
import { EMPTY_DASHBOARD_FILTERS } from './agent-board-filtering'
|
||||
import { selectAgentlessMapWorkspaces } from './agent-map-workspace-visibility'
|
||||
|
||||
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
|
||||
return {
|
||||
paneKey: 'pane-1',
|
||||
ptyId: null,
|
||||
agentType: 'codex',
|
||||
bucket: 'working',
|
||||
dotState: 'working',
|
||||
task: '',
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'occupied',
|
||||
tabId: 'tab-1',
|
||||
leafId: null,
|
||||
repoName: 'Orca',
|
||||
worktreeName: 'Occupied',
|
||||
executionHostId: 'local',
|
||||
startedAt: 0,
|
||||
finishedAt: null,
|
||||
stateChangedAt: 0,
|
||||
unseen: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function workspace(overrides: Partial<DashboardWorkspace> = {}): DashboardWorkspace {
|
||||
return {
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'empty',
|
||||
repoName: 'Orca',
|
||||
worktreeName: 'Empty child',
|
||||
hostKind: 'local',
|
||||
executionHostId: 'local',
|
||||
workspaceKind: 'worktree',
|
||||
workspaceStatusId: 'planned',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('agent map workspace visibility', () => {
|
||||
it('returns only workspaces that have no dashboard card on the same host', () => {
|
||||
const result = selectAgentlessMapWorkspaces({
|
||||
cards: [card()],
|
||||
workspaces: [
|
||||
workspace({ worktreeId: 'occupied', worktreeName: 'Occupied' }),
|
||||
workspace(),
|
||||
workspace({
|
||||
worktreeId: 'occupied',
|
||||
worktreeName: 'Remote twin',
|
||||
hostKind: 'ssh',
|
||||
executionHostId: 'ssh:build-box'
|
||||
})
|
||||
],
|
||||
query: '',
|
||||
filters: EMPTY_DASHBOARD_FILTERS
|
||||
})
|
||||
|
||||
expect(result.map((item) => item.worktreeName)).toEqual(['Empty child', 'Remote twin'])
|
||||
})
|
||||
|
||||
it('applies search and workspace filters to agentless workspaces', () => {
|
||||
const result = selectAgentlessMapWorkspaces({
|
||||
cards: [],
|
||||
workspaces: [
|
||||
workspace({ worktreeName: 'Listener security', review: { number: 42, state: 'open' } }),
|
||||
workspace({ worktreeId: 'other', worktreeName: 'Unrelated', workspaceStatusId: 'active' })
|
||||
],
|
||||
query: 'listener',
|
||||
filters: {
|
||||
projects: ['repo-1'],
|
||||
workspaceStatuses: ['planned'],
|
||||
reviewStates: ['open']
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.map((item) => item.worktreeName)).toEqual(['Listener security'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot'
|
||||
import { filterDashboardWorkspaces, type DashboardFilters } from './agent-board-filtering'
|
||||
import { agentMapWorktreeIdentityFromParts } from './agent-map-workspace-identity'
|
||||
|
||||
export function selectAgentlessMapWorkspaces({
|
||||
cards,
|
||||
workspaces,
|
||||
query,
|
||||
filters
|
||||
}: {
|
||||
cards: DashboardCard[]
|
||||
workspaces: DashboardWorkspace[]
|
||||
query: string
|
||||
filters: DashboardFilters
|
||||
}): DashboardWorkspace[] {
|
||||
const occupiedWorkspaceIds = new Set(
|
||||
cards.map((card) => agentMapWorktreeIdentityFromParts(card.worktreeId, card.executionHostId))
|
||||
)
|
||||
return filterDashboardWorkspaces(workspaces, query, filters).filter(
|
||||
(workspace) =>
|
||||
!occupiedWorkspaceIds.has(
|
||||
agentMapWorktreeIdentityFromParts(workspace.worktreeId, workspace.executionHostId)
|
||||
)
|
||||
)
|
||||
}
|
||||
+31
-12
@@ -77,7 +77,7 @@ function layoutWithWorktreePushCount(count: number) {
|
||||
}
|
||||
|
||||
describe('layoutAgentMapWorktreeLineage', () => {
|
||||
it('preserves the legacy coordinates for branched and linear families', () => {
|
||||
it('keeps branched and linear family coordinates deterministic', () => {
|
||||
const layout = layoutAgentMapWorktreeLineage([
|
||||
{ id: 'root', x: 0, y: 0, radius: 40 },
|
||||
{ id: 'child-a', parentId: 'root', x: 0, y: 0, radius: 30 },
|
||||
@@ -92,36 +92,36 @@ describe('layoutAgentMapWorktreeLineage', () => {
|
||||
id: 'child-a',
|
||||
parentId: 'root',
|
||||
radius: 30,
|
||||
x: -4.33242478827686,
|
||||
y: 47.79022515386755
|
||||
x: -7.740689238053122,
|
||||
y: 42.47172405948656
|
||||
},
|
||||
{
|
||||
id: 'child-b',
|
||||
parentId: 'root',
|
||||
radius: 45,
|
||||
x: 79.07393510462774,
|
||||
y: 194.80695213220156
|
||||
x: 69.93546272327787,
|
||||
y: 175.54837055839306
|
||||
},
|
||||
{
|
||||
id: 'grandchild-a',
|
||||
parentId: 'child-a',
|
||||
radius: 25,
|
||||
x: -4.33242478827686,
|
||||
y: 144.79022515386754
|
||||
x: -7.740689238053122,
|
||||
y: 125.47172405948656
|
||||
},
|
||||
{ id: 'root', radius: 40, x: 21.870755158175434, y: -64.20977484613245 },
|
||||
{ id: 'root', radius: 40, x: 19.097386742612372, y: -55.52827594051344 },
|
||||
{
|
||||
id: 'second-child',
|
||||
parentId: 'second-root',
|
||||
radius: 20,
|
||||
x: -121.42967792073006,
|
||||
y: -79.34629927692441
|
||||
x: -112.9872940755618,
|
||||
y: -73.65876088400047
|
||||
},
|
||||
{
|
||||
id: 'second-root',
|
||||
radius: 35,
|
||||
x: -121.42967792073006,
|
||||
y: -176.3462992769244
|
||||
x: -112.9872940755618,
|
||||
y: -156.65876088400046
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -201,4 +201,23 @@ describe('layoutAgentMapWorktreeLineage', () => {
|
||||
}
|
||||
expect(minimumGap).toBeGreaterThanOrEqual(AGENT_MAP_WORKTREE_GAP)
|
||||
})
|
||||
|
||||
it('packs high-fanout spawn clusters without forcing every workspace below the coordinator', () => {
|
||||
const layout = layoutAgentMapWorktreeLineage([
|
||||
{ id: 'parent', x: 0, y: 0, radius: 32 },
|
||||
...Array.from({ length: 13 }, (_, index) => ({
|
||||
id: `child-${index.toString().padStart(2, '0')}`,
|
||||
clusterParentId: 'parent',
|
||||
x: 0,
|
||||
y: 0,
|
||||
radius: 24
|
||||
}))
|
||||
])
|
||||
const parent = layout.find((worktree) => worktree.id === 'parent')!
|
||||
const children = layout.filter(
|
||||
(worktree) => 'clusterParentId' in worktree && worktree.clusterParentId === 'parent'
|
||||
)
|
||||
|
||||
expect(children.some((child) => child.y <= parent.y)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { AGENT_MAP_WORKTREE_GAP, packAgentMapWorktrees } from './agent-map-worktree-packing'
|
||||
|
||||
const LINEAGE_VERTICAL_GAP = 42
|
||||
const LINEAGE_VERTICAL_GAP = 28
|
||||
const MAX_HIERARCHICAL_CLUSTER_FANOUT = 12
|
||||
const MAX_EXACT_LINEAGE_WORKTREES = 256
|
||||
|
||||
type LineageWorktree = {
|
||||
id: string
|
||||
parentId?: string
|
||||
clusterParentId?: string
|
||||
x: number
|
||||
y: number
|
||||
radius: number
|
||||
@@ -219,10 +221,24 @@ function layoutBoundedLineage<T extends LineageWorktree>(
|
||||
export function layoutAgentMapWorktreeLineage<T extends LineageWorktree>(worktrees: T[]): T[] {
|
||||
const sorted = [...worktrees].sort((a, b) => compareStable(a.id, b.id))
|
||||
const worktreesById = new Map(sorted.map((worktree) => [worktree.id, worktree]))
|
||||
const clusterChildCounts = new Map<string, number>()
|
||||
for (const worktree of sorted) {
|
||||
if (worktree.clusterParentId && worktreesById.has(worktree.clusterParentId)) {
|
||||
clusterChildCounts.set(
|
||||
worktree.clusterParentId,
|
||||
(clusterChildCounts.get(worktree.clusterParentId) ?? 0) + 1
|
||||
)
|
||||
}
|
||||
}
|
||||
const childrenByParent = new Map<string, T[]>()
|
||||
const childIds = new Set<string>()
|
||||
for (const worktree of sorted) {
|
||||
const parentId = worktree.parentId
|
||||
const clusterParentId = worktree.clusterParentId
|
||||
const parentId =
|
||||
clusterParentId &&
|
||||
(clusterChildCounts.get(clusterParentId) ?? 0) <= MAX_HIERARCHICAL_CLUSTER_FANOUT
|
||||
? clusterParentId
|
||||
: worktree.parentId
|
||||
if (!parentId || parentId === worktree.id || !worktreesById.has(parentId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
pointer-events: none;
|
||||
stroke: var(--background);
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 2px;
|
||||
stroke-width: 4px;
|
||||
}
|
||||
|
||||
.agent-map-project-label-frame {
|
||||
@@ -44,10 +44,13 @@
|
||||
|
||||
.agent-map-project-label {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
max-width: calc(100% - 8px);
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
margin-inline: auto;
|
||||
color: var(--foreground);
|
||||
font-family: Geist, var(--font-sans);
|
||||
font-size: 13px;
|
||||
@@ -57,6 +60,15 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-map-project-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
-webkit-text-stroke: 4px var(--background);
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
.agent-map-project-count,
|
||||
.agent-map-worktree-count {
|
||||
fill: var(--muted-foreground);
|
||||
@@ -66,7 +78,7 @@
|
||||
pointer-events: none;
|
||||
stroke: var(--background);
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 2px;
|
||||
stroke-width: 4px;
|
||||
}
|
||||
|
||||
.agent-map-project-count {
|
||||
@@ -125,8 +137,7 @@
|
||||
}
|
||||
|
||||
.agent-map-worktree-label-group.is-visible,
|
||||
.agent-map-worktree-group:hover .agent-map-worktree-label-group,
|
||||
.agent-map-worktree-group:focus-within .agent-map-worktree-label-group {
|
||||
.agent-map-worktree-label-group.is-active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -134,9 +145,7 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.agent-map-worktree-label-group.is-count-visible .agent-map-worktree-count,
|
||||
.agent-map-worktree-group:hover .agent-map-worktree-count,
|
||||
.agent-map-worktree-group:focus-within .agent-map-worktree-count {
|
||||
.agent-map-worktree-label-group.is-count-visible .agent-map-worktree-count {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -157,7 +166,8 @@
|
||||
.agent-map-lineage-link {
|
||||
fill: none;
|
||||
pointer-events: none;
|
||||
stroke: color-mix(in srgb, var(--muted-foreground) 38%, transparent);
|
||||
stroke: color-mix(in srgb, var(--muted-foreground) 42%, transparent);
|
||||
stroke-dasharray: 1 4;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1;
|
||||
@@ -192,14 +202,19 @@
|
||||
}
|
||||
|
||||
.agent-map-agent-node:hover .agent-map-agent-hit,
|
||||
.agent-map-agent-node:focus-visible .agent-map-agent-hit,
|
||||
.agent-map-agent-node.is-selected .agent-map-agent-hit {
|
||||
.agent-map-agent-node:focus-visible .agent-map-agent-hit {
|
||||
fill: color-mix(in srgb, var(--ring) 10%, transparent);
|
||||
stroke: var(--ring);
|
||||
}
|
||||
|
||||
.agent-map-agent-node.is-selected .agent-map-agent-hit {
|
||||
stroke-width: 2;
|
||||
fill: color-mix(in srgb, var(--ring) 18%, transparent);
|
||||
stroke: var(--ring);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.agent-map-agent-node.is-selected .agent-map-agent-mark {
|
||||
fill: color-mix(in srgb, var(--ring) 10%, var(--background));
|
||||
}
|
||||
|
||||
.fleet-status-working .agent-map-agent-mark {
|
||||
@@ -227,7 +242,6 @@
|
||||
pointer-events: none;
|
||||
stroke: var(--background);
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.agent-map-aggregate-node circle {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useState, type RefObject } from 'react'
|
||||
|
||||
export type AgentMapCanvasSize = { width: number; height: number }
|
||||
|
||||
export function useAgentMapCanvasSize(
|
||||
containerRef: RefObject<HTMLDivElement | null>,
|
||||
onResize: () => void
|
||||
): AgentMapCanvasSize {
|
||||
const [size, setSize] = useState<AgentMapCanvasSize>({ width: 800, height: 560 })
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
const measure = (): void => {
|
||||
const next = container.getBoundingClientRect()
|
||||
if (next.width <= 0 || next.height <= 0) {
|
||||
return
|
||||
}
|
||||
onResize()
|
||||
setSize((current) =>
|
||||
current.width === next.width && current.height === next.height
|
||||
? current
|
||||
: { width: next.width, height: next.height }
|
||||
)
|
||||
}
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(container)
|
||||
return () => observer.disconnect()
|
||||
}, [containerRef, onResize])
|
||||
|
||||
return size
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { AgentMapAgentNode } from './agent-map-layout'
|
||||
import type { AgentMapViewport } from './agent-map-viewport-transition'
|
||||
|
||||
type AgentMapSelectedFocusOptions = {
|
||||
agents: AgentMapAgentNode[]
|
||||
selectedPaneKey: string | null
|
||||
viewportRef: { current: AgentMapViewport }
|
||||
resolveFocusZoom: () => number
|
||||
animateViewport: (from: AgentMapViewport, to: AgentMapViewport) => void
|
||||
stopViewportTransition: () => void
|
||||
}
|
||||
|
||||
export function useAgentMapSelectedFocus({
|
||||
agents,
|
||||
selectedPaneKey,
|
||||
viewportRef,
|
||||
resolveFocusZoom,
|
||||
animateViewport,
|
||||
stopViewportTransition
|
||||
}: AgentMapSelectedFocusOptions): void {
|
||||
const focusedAgentRef = useRef<{
|
||||
paneKey: string
|
||||
x: number
|
||||
y: number
|
||||
zoom: number
|
||||
} | null>(null)
|
||||
useEffect(() => {
|
||||
const selected = agents.find((agent) => agent.card.paneKey === selectedPaneKey)
|
||||
if (!selectedPaneKey || !selected) {
|
||||
focusedAgentRef.current = null
|
||||
stopViewportTransition()
|
||||
return
|
||||
}
|
||||
const targetZoom = resolveFocusZoom()
|
||||
const focused = focusedAgentRef.current
|
||||
if (
|
||||
focused?.paneKey === selectedPaneKey &&
|
||||
focused.x === selected.x &&
|
||||
focused.y === selected.y &&
|
||||
focused.zoom === targetZoom
|
||||
) {
|
||||
return
|
||||
}
|
||||
focusedAgentRef.current = {
|
||||
paneKey: selectedPaneKey,
|
||||
x: selected.x,
|
||||
y: selected.y,
|
||||
zoom: targetZoom
|
||||
}
|
||||
animateViewport(viewportRef.current, {
|
||||
center: { x: selected.x, y: selected.y },
|
||||
zoom: targetZoom
|
||||
})
|
||||
}, [
|
||||
agents,
|
||||
animateViewport,
|
||||
resolveFocusZoom,
|
||||
selectedPaneKey,
|
||||
stopViewportTransition,
|
||||
viewportRef
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
startAgentMapViewportTransition,
|
||||
type AgentMapViewport
|
||||
} from './agent-map-viewport-transition'
|
||||
|
||||
type AgentMapViewportTransitionOptions = {
|
||||
durationMs: number
|
||||
reducedMotion: boolean
|
||||
onFrame: (viewport: AgentMapViewport) => void
|
||||
}
|
||||
|
||||
export function useAgentMapViewportTransition({
|
||||
durationMs,
|
||||
reducedMotion,
|
||||
onFrame
|
||||
}: AgentMapViewportTransitionOptions): {
|
||||
animate: (from: AgentMapViewport, to: AgentMapViewport) => void
|
||||
stop: () => void
|
||||
} {
|
||||
const cancelRef = useRef<(() => void) | null>(null)
|
||||
const stop = useCallback((): void => {
|
||||
cancelRef.current?.()
|
||||
cancelRef.current = null
|
||||
}, [])
|
||||
const animate = useCallback(
|
||||
(from: AgentMapViewport, to: AgentMapViewport): void => {
|
||||
stop()
|
||||
if (reducedMotion) {
|
||||
onFrame(to)
|
||||
return
|
||||
}
|
||||
let cancel = (): void => {}
|
||||
cancel = startAgentMapViewportTransition({
|
||||
from,
|
||||
to,
|
||||
durationMs,
|
||||
onFrame,
|
||||
onComplete: () => {
|
||||
if (cancelRef.current === cancel) {
|
||||
cancelRef.current = null
|
||||
}
|
||||
}
|
||||
})
|
||||
cancelRef.current = cancel
|
||||
},
|
||||
[durationMs, onFrame, reducedMotion, stop]
|
||||
)
|
||||
useEffect(() => stop, [stop])
|
||||
return { animate, stop }
|
||||
}
|
||||
@@ -51,6 +51,9 @@ beforeEach(() => {
|
||||
mocks.useLiveDashboardSnapshot.mockClear()
|
||||
mocks.blockingOverlay = false
|
||||
mocks.boardProps = null
|
||||
;(window as unknown as { api: unknown }).api = {
|
||||
dashboard: { openPopout: vi.fn().mockResolvedValue(undefined) }
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -82,13 +85,23 @@ describe('AgentDashboardDrawer', () => {
|
||||
expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('enables workspace ring menus only while the in-window dashboard is mounted', () => {
|
||||
it('hands map rendering to the dedicated popout', () => {
|
||||
const openPopout = vi.mocked(window.api.dashboard.openPopout)
|
||||
render(<AgentDashboardDrawer statusBarVisible />)
|
||||
expect(mocks.boardProps).toBeNull()
|
||||
|
||||
act(() => useAppStore.setState({ agentDashboardDrawerOpen: true }))
|
||||
expect(mocks.boardProps?.workspaceContextMenusEnabled).toBe(true)
|
||||
expect(mocks.boardProps?.onWorkspaceContextMenuOpenChange).toBeTypeOf('function')
|
||||
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)
|
||||
})
|
||||
|
||||
it('reveals a colliding worktree on the card execution host', () => {
|
||||
|
||||
@@ -62,6 +62,11 @@ function AgentDashboardDrawerBody({
|
||||
void window.api.dashboard.openPopout?.()
|
||||
}, [onClose])
|
||||
|
||||
const handleOpenMap = useCallback(() => {
|
||||
onClose()
|
||||
void window.api.dashboard.openPopout?.('map')
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<AgentKanbanBoard
|
||||
snapshot={snapshot}
|
||||
@@ -73,8 +78,7 @@ function AgentDashboardDrawerBody({
|
||||
onRevealAgent={handleRevealAgent}
|
||||
onSpawnAgent={launchDashboardAgent}
|
||||
onClose={onClose}
|
||||
workspaceContextMenusEnabled
|
||||
onWorkspaceContextMenuOpenChange={onMenuOpenChange}
|
||||
onOpenMap={handleOpenMap}
|
||||
headerActions={
|
||||
<AgentDashboardSettingsMenu
|
||||
onSwitchToPopout={handleSwitchToPopout}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useDashboardPopoutBridge } from './useDashboardPopoutBridge'
|
||||
|
||||
/** Opt-in pop-out IPC and snapshot subscriptions. */
|
||||
export default function DashboardPopoutBridge(): null {
|
||||
useDashboardPopoutBridge(true)
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const rendererRoot = join(__dirname, '../..')
|
||||
|
||||
function source(relativePath: string): string {
|
||||
return readFileSync(join(rendererRoot, relativePath), 'utf8')
|
||||
}
|
||||
|
||||
describe('agent dashboard performance isolation', () => {
|
||||
it('keeps all dashboard feature modules out of the disabled app and sidebar path', () => {
|
||||
const app = source('App.tsx')
|
||||
const sidebar = source('components/sidebar/index.tsx')
|
||||
const nav = source('components/sidebar/SidebarNav.tsx')
|
||||
|
||||
expect(app).not.toMatch(/from ['"].*DashboardPopoutBridge['"]/)
|
||||
expect(app).toContain("import('./components/dashboard/DashboardPopoutBridge')")
|
||||
expect(sidebar).not.toMatch(/from ['"].*AgentDashboard(?:Drawer|SidebarHost)['"]/)
|
||||
expect(sidebar).toContain("import('./AgentDashboardSidebarHost')")
|
||||
expect(nav).not.toContain('useAgentBucketCounts')
|
||||
expect(nav).not.toContain('shared/dashboard-snapshot')
|
||||
expect(nav).toContain("import('./AgentDashboardSidebarEntry')")
|
||||
})
|
||||
|
||||
it('keeps map computation out of the main-renderer drawer', () => {
|
||||
const board = source('components/dashboard-popout/AgentKanbanBoard.tsx')
|
||||
const drawer = source('components/dashboard/AgentDashboardDrawer.tsx')
|
||||
|
||||
expect(board).toContain("import('./AgentDashboardMapView')")
|
||||
expect(board).not.toMatch(/from ['"].\/(?:AgentMap|useAgentMap|agent-map-)/)
|
||||
expect(drawer).toContain('initialView="board"')
|
||||
expect(drawer).toContain("openPopout?.('map')")
|
||||
expect(drawer).toContain('onOpenMap={handleOpenMap}')
|
||||
})
|
||||
})
|
||||
+11
@@ -116,6 +116,17 @@ describe('buildDashboardSnapshot folder workspaces', () => {
|
||||
expect(snapshot.filterOptions?.projects).toEqual([
|
||||
{ id: 'folder-workspace:group-1', label: 'Documentation' }
|
||||
])
|
||||
expect(snapshot.workspaces).toEqual([
|
||||
expect.objectContaining({
|
||||
repoId: 'folder-workspace:group-1',
|
||||
worktreeId: WORKSPACE_ID,
|
||||
repoName: 'Documentation',
|
||||
worktreeName: 'Docs workspace',
|
||||
workspaceKind: 'folder',
|
||||
hostKind: 'ssh',
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('classifies a folder workspace from its own runtime host stamp', () => {
|
||||
|
||||
@@ -145,6 +145,23 @@ describe('buildDashboardSnapshot', () => {
|
||||
)
|
||||
|
||||
expect(snapshot.cards).toEqual([])
|
||||
expect(snapshot.workspaces).toEqual([
|
||||
expect.objectContaining({
|
||||
repoId: 'r1',
|
||||
worktreeId: 'w1',
|
||||
repoName: 'Repo One',
|
||||
worktreeName: 'wt-one',
|
||||
hostKind: 'local',
|
||||
executionHostId: 'local',
|
||||
workspaceKind: 'worktree'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
repoId: 'r2',
|
||||
worktreeId: 'w2',
|
||||
repoName: 'Repo Two',
|
||||
worktreeName: 'wt-two'
|
||||
})
|
||||
])
|
||||
expect(snapshot.filterOptions).toEqual({
|
||||
projects: [
|
||||
{ id: 'r1', label: 'Repo One' },
|
||||
@@ -579,6 +596,7 @@ describe('buildDashboardSnapshot', () => {
|
||||
// host-input resolution off the sidebar's per-status-tick rebuild.
|
||||
expect(snapshot.cards[0].ptyId).toBe('pty1')
|
||||
expect(snapshot.cards[0].terminalInput).toBeUndefined()
|
||||
expect(snapshot.workspaces).toBeUndefined()
|
||||
expect(linkedReviewReads).toBe(0)
|
||||
expect(hostIdentityReads).toBe(0)
|
||||
expect(mapMetadataCalls.hostKind).not.toHaveBeenCalled()
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
DASHBOARD_MAX_MAP_WORKSPACES,
|
||||
dashboardCardDisplayState,
|
||||
type DashboardBucket,
|
||||
type DashboardCard,
|
||||
type DashboardCardDotState,
|
||||
type DashboardCardSubagent,
|
||||
type DashboardSnapshot
|
||||
type DashboardSnapshot,
|
||||
type DashboardWorkspace
|
||||
} 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 {
|
||||
resolveDashboardCardTerminalInput,
|
||||
@@ -54,6 +54,8 @@ import {
|
||||
buildDashboardWorktreeLaunchOptions,
|
||||
type DashboardLaunchDetectionState
|
||||
} from './dashboard-worktree-launch-options'
|
||||
import { buildDashboardSnapshotFilterOptions } from './dashboard-snapshot-filter-options'
|
||||
import { dashboardBucketForDotState } from './dashboard-card-bucket'
|
||||
|
||||
/** The store slices the snapshot builder reads. Kept as a Pick so unit tests
|
||||
* can pass a partial store without constructing the whole AppState. */
|
||||
@@ -75,21 +77,6 @@ export type DashboardSnapshotState = Pick<
|
||||
DashboardCardContextState &
|
||||
Partial<DashboardCardTerminalInputState & DashboardLaunchDetectionState>
|
||||
|
||||
function bucketForState(state: DashboardCardDotState): DashboardBucket {
|
||||
switch (state) {
|
||||
case 'working':
|
||||
return 'working'
|
||||
case 'done':
|
||||
return 'done'
|
||||
case 'idle':
|
||||
return 'idle'
|
||||
// blocked | waiting — the agent needs the user.
|
||||
case 'blocked':
|
||||
case 'waiting':
|
||||
return 'attention'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the serializable dashboard snapshot from the live renderer store.
|
||||
* Reuses the exact per-worktree row machinery the sidebar uses
|
||||
@@ -103,6 +90,8 @@ export function buildDashboardSnapshot(
|
||||
options: { includeCardDetails?: boolean; includeFilterOptions?: boolean } = {}
|
||||
): DashboardSnapshot {
|
||||
const cards: DashboardCard[] = []
|
||||
const workspaces: DashboardWorkspace[] | undefined =
|
||||
options.includeCardDetails === false ? undefined : []
|
||||
const clientHost = readDashboardClientHost()
|
||||
const repoIconsByRepoId: Record<string, RepoIcon | null> = {}
|
||||
const includeCardDetails = options.includeCardDetails !== false
|
||||
@@ -112,26 +101,7 @@ export function buildDashboardSnapshot(
|
||||
const filterOptions =
|
||||
options.includeFilterOptions === false
|
||||
? undefined
|
||||
: {
|
||||
// Why: filterOptions is snapshot-level, so an over-long project label
|
||||
// costs the WHOLE board, not one card. Bound it at the producer.
|
||||
projects: [
|
||||
...new Map(
|
||||
activeWorktrees.map((workspace) => [workspace.projectId, workspace])
|
||||
).values()
|
||||
].map((workspace) => ({
|
||||
id: workspace.projectId,
|
||||
label: boundedLabel(workspace.projectName)
|
||||
})),
|
||||
workspaceStatuses: (state.workspaceStatuses && state.workspaceStatuses.length > 0
|
||||
? state.workspaceStatuses
|
||||
: DEFAULT_WORKSPACE_STATUSES
|
||||
).map((status) => ({
|
||||
id: status.id,
|
||||
label: status.label,
|
||||
color: status.color
|
||||
}))
|
||||
}
|
||||
: buildDashboardSnapshotFilterOptions(state, activeWorktrees)
|
||||
let singletonOrchestration: ReturnType<typeof selectRuntimeAgentOrchestrationForWorktree> | null =
|
||||
null
|
||||
let orchestrationByWorktree: ReturnType<typeof selectRuntimeAgentOrchestrationBatch> | null = null
|
||||
@@ -214,6 +184,21 @@ export function buildDashboardSnapshot(
|
||||
const context = includeCardDetails
|
||||
? resolveDashboardCardContext(state, repo, worktree)
|
||||
: undefined
|
||||
if (workspaces && workspaces.length < DASHBOARD_MAX_MAP_WORKSPACES) {
|
||||
workspaces.push({
|
||||
repoId: workspace.projectId,
|
||||
worktreeId,
|
||||
repoName: boundedLabel(workspace.projectName),
|
||||
worktreeName: boundedLabel(worktree.displayName),
|
||||
...(parentWorktreeId ? { parentWorktreeId } : {}),
|
||||
...dashboardCardMapWorkspaceMetadata(workspace, null, undefined, clientHost.platform),
|
||||
workspaceStatusId: context?.workspaceStatus.id,
|
||||
workspaceStatusLabel: context?.workspaceStatus.label,
|
||||
workspaceStatusColor: context?.workspaceStatus.color,
|
||||
hasReview: context?.hasReview,
|
||||
review: context?.review
|
||||
})
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
// Child rows have no pane of their own; the board lists top-level agents.
|
||||
@@ -242,7 +227,7 @@ export function buildDashboardSnapshot(
|
||||
const unseen =
|
||||
!isTitleDerived &&
|
||||
(state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt
|
||||
const bucket = bucketForState(dashboardCardDisplayState({ dotState, unseen }))
|
||||
const bucket = dashboardBucketForDotState(dashboardCardDisplayState({ dotState, unseen }))
|
||||
// Why: only a live pty can open a preview terminal, and only a
|
||||
// card-rendering caller can open one — the sidebar's bucket counts must
|
||||
// not pay host resolution on every agent-status tick.
|
||||
@@ -260,6 +245,7 @@ export function buildDashboardSnapshot(
|
||||
osRelease: clientHost.osRelease
|
||||
})
|
||||
: null
|
||||
const finishedAt = lastEnteredDoneAt(row)
|
||||
// Only repos that actually contribute a card ship their icon.
|
||||
repoIconsByRepoId[workspace.projectId] = workspace.repoIcon
|
||||
|
||||
@@ -297,7 +283,7 @@ export function buildDashboardSnapshot(
|
||||
lastUserMessage: isTitleDerived ? undefined : nonEmpty(row.entry.prompt),
|
||||
lastAgentMessage: isTitleDerived ? undefined : nonEmpty(row.entry.lastAssistantMessage),
|
||||
startedAt: row.startedAt,
|
||||
finishedAt: lastEnteredDoneAt(row),
|
||||
finishedAt,
|
||||
stateChangedAt: row.entry.stateStartedAt || row.startedAt,
|
||||
// Same derivation as WorktreeCardAgents' unvisitedByPaneKey, so the
|
||||
// board and the sidebar bold/mute the same agents at the same time.
|
||||
@@ -312,12 +298,19 @@ export function buildDashboardSnapshot(
|
||||
return {
|
||||
generatedAt: now,
|
||||
cards,
|
||||
...(workspaces ? { workspaces } : {}),
|
||||
showIdle,
|
||||
filterOptions,
|
||||
// Only the dashboard surfaces offer a launcher; the count-only rebuild that
|
||||
// feeds the sidebar must not pay for host-detection lookups.
|
||||
...(includeCardDetails
|
||||
? { launchableAgentsByWorktreeId: buildDashboardWorktreeLaunchOptions(state, cards) }
|
||||
? {
|
||||
launchableAgentsByWorktreeId: buildDashboardWorktreeLaunchOptions(
|
||||
state,
|
||||
cards,
|
||||
workspaces
|
||||
)
|
||||
}
|
||||
: {}),
|
||||
repoIconsByRepoId
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { DashboardBucket, DashboardCardDotState } from '../../../../shared/dashboard-snapshot'
|
||||
|
||||
export function dashboardBucketForDotState(state: DashboardCardDotState): DashboardBucket {
|
||||
switch (state) {
|
||||
case 'working':
|
||||
return 'working'
|
||||
case 'done':
|
||||
return 'done'
|
||||
case 'idle':
|
||||
return 'idle'
|
||||
case 'blocked':
|
||||
case 'waiting':
|
||||
return 'attention'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { DashboardFilterOptions } from '../../../../shared/dashboard-snapshot'
|
||||
import { DEFAULT_WORKSPACE_STATUSES } from '../../../../shared/workspace-statuses'
|
||||
import { boundedLabel } from './dashboard-card-labels'
|
||||
import type { ActiveDashboardWorkspace } from './dashboard-snapshot-workspaces'
|
||||
|
||||
export function buildDashboardSnapshotFilterOptions(
|
||||
state: Partial<Pick<AppState, 'workspaceStatuses'>>,
|
||||
activeWorkspaces: ActiveDashboardWorkspace[]
|
||||
): DashboardFilterOptions {
|
||||
const projects = [
|
||||
...new Map(activeWorkspaces.map((workspace) => [workspace.projectId, workspace])).values()
|
||||
].map((workspace) => ({
|
||||
id: workspace.projectId,
|
||||
label: boundedLabel(workspace.projectName)
|
||||
}))
|
||||
const workspaceStatuses = (
|
||||
state.workspaceStatuses && state.workspaceStatuses.length > 0
|
||||
? state.workspaceStatuses
|
||||
: DEFAULT_WORKSPACE_STATUSES
|
||||
).map((status) => ({
|
||||
id: status.id,
|
||||
label: status.label,
|
||||
color: status.color
|
||||
}))
|
||||
return { projects, workspaceStatuses }
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
DashboardCardWorkspaceKind
|
||||
} from '../../../../shared/dashboard-snapshot'
|
||||
import type { RepoIcon } from '../../../../shared/repo-icon'
|
||||
import { getWorktreeExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { getWorktreeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
|
||||
@@ -111,7 +111,11 @@ export function dashboardCardMapWorkspaceMetadata(
|
||||
ptyId: string | null,
|
||||
terminalInput: DashboardCard['terminalInput'],
|
||||
clientPlatform: NodeJS.Platform
|
||||
): Pick<DashboardCard, 'hostKind' | 'executionHostId' | 'workspaceKind'> {
|
||||
): {
|
||||
hostKind: DashboardCardHostKind
|
||||
executionHostId: ExecutionHostId
|
||||
workspaceKind: DashboardCardWorkspaceKind
|
||||
} {
|
||||
return {
|
||||
hostKind: dashboardCardHostKind(workspace, ptyId, terminalInput, clientPlatform),
|
||||
executionHostId: getWorktreeExecutionHostId(workspace.worktree, workspace.repo ?? undefined),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DASHBOARD_MAX_LAUNCH_WORKTREES } from '../../../../shared/dashboard-snapshot'
|
||||
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
|
||||
import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { buildDashboardWorktreeLaunchOptions } from './dashboard-worktree-launch-options'
|
||||
|
||||
@@ -71,6 +71,25 @@ describe('buildDashboardWorktreeLaunchOptions', () => {
|
||||
expect(options).toEqual({ 'worktree-1': ['codex', 'gemini'] })
|
||||
})
|
||||
|
||||
it('publishes detected launch choices for workspaces without cards', () => {
|
||||
const workspace: DashboardWorkspace = {
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'empty-worktree',
|
||||
repoName: 'Orca',
|
||||
worktreeName: 'Empty',
|
||||
hostKind: 'local',
|
||||
executionHostId: 'local',
|
||||
workspaceKind: 'worktree'
|
||||
}
|
||||
const options = buildDashboardWorktreeLaunchOptions(
|
||||
state({ detectedAgentIds: ['claude', 'codex'] }),
|
||||
[],
|
||||
[workspace]
|
||||
)
|
||||
|
||||
expect(options).toEqual({ 'empty-worktree': ['claude', 'codex'] })
|
||||
})
|
||||
|
||||
it('uses each git workspace execution host instead of local detection', () => {
|
||||
const options = buildDashboardWorktreeLaunchOptions(
|
||||
state({
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { AppState } from '@/store/types'
|
||||
import { parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
DASHBOARD_MAX_LAUNCH_WORKTREES,
|
||||
type DashboardCard
|
||||
type DashboardCard,
|
||||
type DashboardWorkspace
|
||||
} from '../../../../shared/dashboard-snapshot'
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
import {
|
||||
@@ -78,11 +79,16 @@ function detectedAgentsForWorktree(
|
||||
/** Host-detected choices plus providers already proven to run in the workspace. */
|
||||
export function buildDashboardWorktreeLaunchOptions(
|
||||
state: DashboardLaunchOptionState,
|
||||
cards: readonly DashboardCard[]
|
||||
cards: readonly DashboardCard[],
|
||||
workspaces: readonly DashboardWorkspace[] = []
|
||||
): Record<string, TuiAgent[]> {
|
||||
const catalog = buildDashboardLaunchCatalog(state)
|
||||
const cardsByWorktreeId = new Map<string, DashboardCard[]>()
|
||||
const repoIdByWorktreeId = new Map(
|
||||
workspaces.map((workspace) => [workspace.worktreeId, workspace.repoId])
|
||||
)
|
||||
for (const card of cards) {
|
||||
repoIdByWorktreeId.set(card.worktreeId, card.repoId)
|
||||
const existing = cardsByWorktreeId.get(card.worktreeId)
|
||||
if (existing) {
|
||||
existing.push(card)
|
||||
@@ -92,14 +98,15 @@ export function buildDashboardWorktreeLaunchOptions(
|
||||
}
|
||||
|
||||
const result: Record<string, TuiAgent[]> = {}
|
||||
for (const [worktreeId, worktreeCards] of cardsByWorktreeId) {
|
||||
for (const [worktreeId, repoId] of repoIdByWorktreeId) {
|
||||
// Past the validator's bound the snapshot itself would be rejected, so the
|
||||
// launcher goes quiet for the tail rather than taking the board down.
|
||||
if (Object.keys(result).length >= DASHBOARD_MAX_LAUNCH_WORKTREES) {
|
||||
break
|
||||
}
|
||||
const worktreeCards = cardsByWorktreeId.get(worktreeId) ?? []
|
||||
const available = new Set<TuiAgent>(
|
||||
detectedAgentsForWorktree(state, worktreeId, worktreeCards[0].repoId, catalog)
|
||||
detectedAgentsForWorktree(state, worktreeId, repoId, catalog)
|
||||
)
|
||||
for (const card of worktreeCards) {
|
||||
if (isTuiAgent(card.agentType)) {
|
||||
|
||||
@@ -138,7 +138,7 @@ describe('ExperimentalPane', () => {
|
||||
<ExperimentalPane settings={settings} updateSettings={vi.fn()} />
|
||||
)
|
||||
|
||||
expect(settings.experimentalAgentDashboardPopout).toBe(false)
|
||||
expect(settings.experimentalAgentDashboardPopout).toBeUndefined()
|
||||
expect(markup).toContain('Agent Dashboard')
|
||||
expect(markup).toContain('Monitor agents that need you, are working, or are done')
|
||||
expect(getExperimentalPaneSearchEntries().map((entry) => entry.title)).toContain(
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { LayoutDashboard, MessageCircleQuestion } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { DASHBOARD_BUCKET_ORDER, type DashboardBucket } from '../../../../shared/dashboard-snapshot'
|
||||
import { useAgentBucketCounts } from '@/components/dashboard/useAgentBucketCounts'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const DASHBOARD_BUCKET_DOT_CLASS: Record<'working' | 'done' | 'idle', string> = {
|
||||
working: 'bg-yellow-500',
|
||||
done: 'bg-emerald-500',
|
||||
idle: 'bg-neutral-500/50'
|
||||
}
|
||||
|
||||
function dashboardBucketLabel(bucket: DashboardBucket): string {
|
||||
switch (bucket) {
|
||||
case 'attention':
|
||||
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,
|
||||
showIdle
|
||||
}: {
|
||||
counts: Record<DashboardBucket, number>
|
||||
showIdle: boolean
|
||||
}): React.JSX.Element | null {
|
||||
const active = DASHBOARD_BUCKET_ORDER.filter(
|
||||
(bucket) => counts[bucket] > 0 && (bucket !== 'idle' || showIdle)
|
||||
)
|
||||
if (active.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
{active.map((bucket) => (
|
||||
<span
|
||||
key={bucket}
|
||||
aria-label={`${dashboardBucketLabel(bucket)}: ${counts[bucket]}`}
|
||||
className="inline-flex items-center gap-1 text-[10px] tabular-nums text-worktree-sidebar-foreground/55"
|
||||
>
|
||||
{bucket === 'attention' ? (
|
||||
<MessageCircleQuestion className="size-2.5 text-amber-500" aria-hidden />
|
||||
) : (
|
||||
<span className={cn('size-1.5 rounded-full', DASHBOARD_BUCKET_DOT_CLASS[bucket])} />
|
||||
)}
|
||||
{counts[bucket]}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AgentDashboardSidebarEntry(): React.JSX.Element {
|
||||
const dashboardBucketCounts = useAgentBucketCounts()
|
||||
const showIdle = useAppStore((s) => s.settings?.experimentalAgentDashboardShowIdle === true)
|
||||
const openAsPopout = useAppStore((s) => s.settings?.experimentalAgentDashboardMode === 'popout')
|
||||
const drawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen)
|
||||
const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (openAsPopout) {
|
||||
void window.api.dashboard.openPopout()
|
||||
} else {
|
||||
setAgentDashboardDrawerOpen(!drawerOpen)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
|
||||
'text-worktree-sidebar-foreground/60 hover:bg-worktree-sidebar-foreground/8'
|
||||
)}
|
||||
>
|
||||
<LayoutDashboard
|
||||
className="size-4 shrink-0 text-worktree-sidebar-foreground/30"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
<span className="flex-1">{translate('dashboard.sidebar.label', 'Agent Dashboard')}</span>
|
||||
<DashboardBucketCounts counts={dashboardBucketCounts} showIdle={showIdle} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import AgentDashboardSidebarHost from './AgentDashboardSidebarHost'
|
||||
|
||||
vi.mock('@/components/dashboard/AgentDashboardDrawer', () => ({
|
||||
AgentDashboardDrawer: () => null
|
||||
}))
|
||||
|
||||
const initialState = useAppStore.getInitialState()
|
||||
|
||||
beforeEach(() => {
|
||||
useAppStore.setState({ agentDashboardDrawerOpen: false }, false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
useAppStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
describe('AgentDashboardSidebarHost', () => {
|
||||
it('closes the dashboard when the workspace board opens', async () => {
|
||||
useAppStore.setState({ agentDashboardDrawerOpen: true })
|
||||
render(
|
||||
<AgentDashboardSidebarHost
|
||||
sidebarOpen
|
||||
workspaceBoardOpen
|
||||
closeWorkspaceBoard={vi.fn()}
|
||||
statusBarVisible
|
||||
/>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false))
|
||||
})
|
||||
|
||||
it('closes the workspace board when the dashboard opens', async () => {
|
||||
const closeWorkspaceBoard = vi.fn()
|
||||
const view = render(
|
||||
<AgentDashboardSidebarHost
|
||||
sidebarOpen
|
||||
workspaceBoardOpen={false}
|
||||
closeWorkspaceBoard={closeWorkspaceBoard}
|
||||
statusBarVisible
|
||||
/>
|
||||
)
|
||||
|
||||
act(() => useAppStore.setState({ agentDashboardDrawerOpen: true }))
|
||||
view.rerender(
|
||||
<AgentDashboardSidebarHost
|
||||
sidebarOpen
|
||||
workspaceBoardOpen={false}
|
||||
closeWorkspaceBoard={closeWorkspaceBoard}
|
||||
statusBarVisible
|
||||
/>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(closeWorkspaceBoard).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('clears an open dashboard when the sidebar closes', async () => {
|
||||
useAppStore.setState({ agentDashboardDrawerOpen: true })
|
||||
render(
|
||||
<AgentDashboardSidebarHost
|
||||
sidebarOpen={false}
|
||||
workspaceBoardOpen={false}
|
||||
closeWorkspaceBoard={vi.fn()}
|
||||
statusBarVisible
|
||||
/>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { AgentDashboardDrawer } from '@/components/dashboard/AgentDashboardDrawer'
|
||||
|
||||
type AgentDashboardSidebarHostProps = {
|
||||
sidebarOpen: boolean
|
||||
workspaceBoardOpen: boolean
|
||||
closeWorkspaceBoard: () => void
|
||||
leftSidebarStyle?: React.CSSProperties
|
||||
statusBarVisible: boolean
|
||||
}
|
||||
|
||||
/** Opt-in dashboard coordination stays outside the normal sidebar path. */
|
||||
export default function AgentDashboardSidebarHost({
|
||||
sidebarOpen,
|
||||
workspaceBoardOpen,
|
||||
closeWorkspaceBoard,
|
||||
leftSidebarStyle,
|
||||
statusBarVisible
|
||||
}: AgentDashboardSidebarHostProps): React.JSX.Element | null {
|
||||
const drawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen)
|
||||
const setDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sidebarOpen && drawerOpen) {
|
||||
setDrawerOpen(false)
|
||||
}
|
||||
}, [drawerOpen, setDrawerOpen, sidebarOpen])
|
||||
useEffect(() => {
|
||||
if (drawerOpen) {
|
||||
closeWorkspaceBoard()
|
||||
}
|
||||
}, [closeWorkspaceBoard, drawerOpen])
|
||||
useEffect(() => {
|
||||
if (workspaceBoardOpen) {
|
||||
setDrawerOpen(false)
|
||||
}
|
||||
}, [setDrawerOpen, workspaceBoardOpen])
|
||||
|
||||
return sidebarOpen ? (
|
||||
<AgentDashboardDrawer leftSidebarStyle={leftSidebarStyle} statusBarVisible={statusBarVisible} />
|
||||
) : null
|
||||
}
|
||||
@@ -219,63 +219,4 @@ describe('Sidebar', () => {
|
||||
|
||||
expect(fetchAllWorktrees).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('companion board mutual exclusion', () => {
|
||||
function renderWithDashboardOpen(): {
|
||||
view: ReturnType<typeof render>
|
||||
setAgentDashboardDrawerOpen: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
setSidebarState(getDefaultSettings(tmpdir()))
|
||||
const setAgentDashboardDrawerOpen = vi.fn()
|
||||
mocks.state = { ...mocks.state, agentDashboardDrawerOpen: true, setAgentDashboardDrawerOpen }
|
||||
mocks.panel = {
|
||||
workspaceBoardOpen: false,
|
||||
workspaceBoardRenderedOpen: false,
|
||||
workspaceBoardDragPreviewOpen: false
|
||||
}
|
||||
return { view: render(sidebarElement()), setAgentDashboardDrawerOpen }
|
||||
}
|
||||
|
||||
it('keeps the agent dashboard open while a worktree drag only previews the board', () => {
|
||||
const { view, setAgentDashboardDrawerOpen } = renderWithDashboardOpen()
|
||||
|
||||
mocks.panel = {
|
||||
workspaceBoardOpen: false,
|
||||
workspaceBoardRenderedOpen: true,
|
||||
workspaceBoardDragPreviewOpen: true
|
||||
}
|
||||
view.rerender(sidebarElement())
|
||||
|
||||
expect(setAgentDashboardDrawerOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes the agent dashboard when the workspace board is actually opened', () => {
|
||||
const { view, setAgentDashboardDrawerOpen } = renderWithDashboardOpen()
|
||||
|
||||
mocks.panel = {
|
||||
workspaceBoardOpen: true,
|
||||
workspaceBoardRenderedOpen: true,
|
||||
workspaceBoardDragPreviewOpen: false
|
||||
}
|
||||
view.rerender(sidebarElement())
|
||||
|
||||
expect(setAgentDashboardDrawerOpen).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('closes the workspace board when the agent dashboard opens', () => {
|
||||
setSidebarState(getDefaultSettings(tmpdir()))
|
||||
mocks.panel = {
|
||||
workspaceBoardOpen: true,
|
||||
workspaceBoardRenderedOpen: true,
|
||||
workspaceBoardDragPreviewOpen: false
|
||||
}
|
||||
const view = render(sidebarElement())
|
||||
mocks.closeWorkspaceBoard.mockClear()
|
||||
|
||||
mocks.state = { ...mocks.state, agentDashboardDrawerOpen: true }
|
||||
view.rerender(sidebarElement())
|
||||
|
||||
expect(mocks.closeWorkspaceBoard).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
@@ -21,6 +22,7 @@ const mocks = vi.hoisted(() => ({
|
||||
checkLinearConnection: vi.fn(),
|
||||
hasPairedMobileDevice: false,
|
||||
agentBucketCounts: { attention: 0, working: 0, done: 0, idle: 0 },
|
||||
getAgentBucketCounts: vi.fn(),
|
||||
dismissMobileOnboardingBadge: vi.fn(),
|
||||
setSetupGuideSidebarDismissed: vi.fn()
|
||||
}))
|
||||
@@ -41,7 +43,10 @@ vi.mock('@/components/activity/useActivityUnreadCount', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/components/dashboard/useAgentBucketCounts', () => ({
|
||||
useAgentBucketCounts: () => mocks.agentBucketCounts
|
||||
useAgentBucketCounts: () => {
|
||||
mocks.getAgentBucketCounts()
|
||||
return mocks.agentBucketCounts
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
@@ -243,6 +248,7 @@ describe('SidebarNav', () => {
|
||||
const container = await renderSidebarNav()
|
||||
|
||||
expect(queryButtonByText(container, 'Agent Dashboard')).toBeNull()
|
||||
expect(mocks.getAgentBucketCounts).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mounts the Agent Dashboard row after opt-in', async () => {
|
||||
@@ -254,7 +260,8 @@ describe('SidebarNav', () => {
|
||||
})
|
||||
const container = await renderSidebarNav()
|
||||
|
||||
expect(queryButtonByText(container, 'Agent Dashboard')).not.toBeNull()
|
||||
await waitFor(() => expect(queryButtonByText(container, 'Agent Dashboard')).not.toBeNull())
|
||||
expect(mocks.getAgentBucketCounts).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses a question glyph only for the Needs You count', async () => {
|
||||
@@ -268,6 +275,9 @@ describe('SidebarNav', () => {
|
||||
})
|
||||
const container = await renderSidebarNav()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('[aria-label="Needs You: 2"]')).not.toBeNull()
|
||||
)
|
||||
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"]')
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Bell,
|
||||
CalendarClock,
|
||||
EyeOff,
|
||||
LayoutDashboard,
|
||||
MessageCircleQuestion,
|
||||
Search,
|
||||
Smartphone
|
||||
} from 'lucide-react'
|
||||
import { Bell, CalendarClock, EyeOff, Search, Smartphone } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppStore } from '@/store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { DASHBOARD_BUCKET_ORDER, type DashboardBucket } from '../../../../shared/dashboard-snapshot'
|
||||
import { useAgentBucketCounts } from '@/components/dashboard/useAgentBucketCounts'
|
||||
import { useActivityUnreadCount } from '@/components/activity/useActivityUnreadCount'
|
||||
import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
|
||||
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
|
||||
@@ -25,6 +15,7 @@ import { SetupGuideSidebarEntry } from './SetupGuideSidebarEntry'
|
||||
import { SidebarTaskNavButton } from './SidebarTaskNavButton'
|
||||
import { HideSidebarMenu } from './sidebar-nav-controls'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { lazyWithRetry } from '@/lib/lazy-with-retry'
|
||||
|
||||
export { getSetupGuideSidebarEntryReady, shouldShowSetupGuideEntry } from './SetupGuideSidebarEntry'
|
||||
|
||||
@@ -40,14 +31,6 @@ export function shouldShowAgentDashboardButton(
|
||||
return settings?.experimentalAgentDashboardPopout === true
|
||||
}
|
||||
|
||||
// Why: in-window is the default surface; only an explicit 'popout' choice opens
|
||||
// the separate OS window.
|
||||
function isAgentDashboardPopoutMode(
|
||||
settings: Pick<GlobalSettings, 'experimentalAgentDashboardMode'> | null | undefined
|
||||
): boolean {
|
||||
return settings?.experimentalAgentDashboardMode === 'popout'
|
||||
}
|
||||
|
||||
export function shouldShowMobileButton(
|
||||
settings: Pick<GlobalSettings, 'showMobileButton'> | null | undefined
|
||||
): boolean {
|
||||
@@ -60,93 +43,7 @@ export function shouldShowAutomationsButton(
|
||||
return settings?.showAutomationsButton !== false
|
||||
}
|
||||
|
||||
const DASHBOARD_BUCKET_DOT_CLASS: Record<'working' | 'done' | 'idle', string> = {
|
||||
working: 'bg-yellow-500',
|
||||
done: 'bg-emerald-500',
|
||||
idle: 'bg-neutral-500/50'
|
||||
}
|
||||
|
||||
function dashboardBucketLabel(bucket: DashboardBucket): string {
|
||||
switch (bucket) {
|
||||
case 'attention':
|
||||
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,
|
||||
showIdle
|
||||
}: {
|
||||
counts: Record<DashboardBucket, number>
|
||||
showIdle: boolean
|
||||
}): React.JSX.Element | null {
|
||||
const active = DASHBOARD_BUCKET_ORDER.filter(
|
||||
(bucket) => counts[bucket] > 0 && (bucket !== 'idle' || showIdle)
|
||||
)
|
||||
if (active.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
{active.map((bucket) => (
|
||||
<span
|
||||
key={bucket}
|
||||
aria-label={`${dashboardBucketLabel(bucket)}: ${counts[bucket]}`}
|
||||
className="inline-flex items-center gap-1 text-[10px] tabular-nums text-worktree-sidebar-foreground/55"
|
||||
>
|
||||
{bucket === 'attention' ? (
|
||||
<MessageCircleQuestion className="size-2.5 text-amber-500" aria-hidden />
|
||||
) : (
|
||||
<span className={cn('size-1.5 rounded-full', DASHBOARD_BUCKET_DOT_CLASS[bucket])} />
|
||||
)}
|
||||
{counts[bucket]}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Why: keep the dashboard's broad aggregate subscriptions out of SidebarNav so
|
||||
// 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)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (openAsPopout) {
|
||||
void window.api.dashboard.openPopout()
|
||||
} else {
|
||||
// Why: like the workspace board trigger, the entry toggles its
|
||||
// companion drawer — sidebar clicks do not auto-dismiss it.
|
||||
setAgentDashboardDrawerOpen(!drawerOpen)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
|
||||
'text-worktree-sidebar-foreground/60 hover:bg-worktree-sidebar-foreground/8'
|
||||
)}
|
||||
>
|
||||
<LayoutDashboard
|
||||
className="size-4 shrink-0 text-worktree-sidebar-foreground/30"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
<span className="flex-1">{translate('dashboard.sidebar.label', 'Agent Dashboard')}</span>
|
||||
<DashboardBucketCounts counts={dashboardBucketCounts} showIdle={showIdle} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
const AgentDashboardSidebarEntry = lazyWithRetry(() => import('./AgentDashboardSidebarEntry'))
|
||||
|
||||
const SidebarNav = React.memo(function SidebarNav() {
|
||||
// Why: this memo boundary needs its own language subscription, while
|
||||
@@ -159,8 +56,13 @@ const SidebarNav = React.memo(function SidebarNav() {
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const activeView = useAppStore((s) => s.activeView)
|
||||
const showAgentsButton = useAppStore((s) => shouldShowAgentsButton(s.settings))
|
||||
const showAgentDashboardButton = useAppStore((s) => shouldShowAgentDashboardButton(s.settings))
|
||||
const experimentalSidebarButtons = useAppStore(
|
||||
(s) =>
|
||||
(shouldShowAgentsButton(s.settings) ? 1 : 0) |
|
||||
(shouldShowAgentDashboardButton(s.settings) ? 2 : 0)
|
||||
)
|
||||
const showAgentsButton = (experimentalSidebarButtons & 1) !== 0
|
||||
const showAgentDashboardButton = (experimentalSidebarButtons & 2) !== 0
|
||||
const showAutomationsButton = useAppStore((s) => shouldShowAutomationsButton(s.settings))
|
||||
const showMobileButton = useAppStore((s) => shouldShowMobileButton(s.settings))
|
||||
const automationsActive = activeView === 'automations'
|
||||
@@ -211,7 +113,11 @@ const SidebarNav = React.memo(function SidebarNav() {
|
||||
<HideSidebarMenu onHide={hideAutomationsButton} />
|
||||
</ContextMenu>
|
||||
) : null}
|
||||
{showAgentDashboardButton ? <AgentDashboardSidebarEntry /> : null}
|
||||
{showAgentDashboardButton ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<AgentDashboardSidebarEntry />
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
{showAgentsButton ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,7 +8,6 @@ import SetupScriptPromptCard from './SetupScriptPromptCard'
|
||||
import WorktreeList from './WorktreeList'
|
||||
import SidebarToolbar from './SidebarToolbar'
|
||||
import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer'
|
||||
import { AgentDashboardDrawer } from '@/components/dashboard/AgentDashboardDrawer'
|
||||
import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FolderPlus, Loader2 } from 'lucide-react'
|
||||
@@ -23,6 +22,7 @@ const RemoveFolderDialog = lazyWithRetry(() => import('./RemoveFolderDialog'))
|
||||
const WorktreeVisibilityDialog = lazyWithRetry(() => import('./WorktreeVisibilityDialog'))
|
||||
const OrcaYamlTrustDialog = lazyWithRetry(() => import('./OrcaYamlTrustDialog'))
|
||||
const ForgetSshWorkspaceDialog = lazyWithRetry(() => import('./ForgetSshWorkspaceDialog'))
|
||||
const AgentDashboardSidebarHost = lazyWithRetry(() => import('./AgentDashboardSidebarHost'))
|
||||
|
||||
const MIN_WIDTH = 220
|
||||
const MAX_WIDTH = 500
|
||||
@@ -92,28 +92,6 @@ function Sidebar({
|
||||
}
|
||||
}, [closeWorkspaceBoard, sidebarOpen, workspaceBoardRenderedOpen])
|
||||
|
||||
const agentDashboardDrawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen)
|
||||
const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen)
|
||||
useEffect(() => {
|
||||
if (!sidebarOpen && agentDashboardDrawerOpen) {
|
||||
setAgentDashboardDrawerOpen(false)
|
||||
}
|
||||
}, [agentDashboardDrawerOpen, setAgentDashboardDrawerOpen, sidebarOpen])
|
||||
// Why: both companion boards expand into the same space beside the sidebar,
|
||||
// so the most recently opened one dismisses the other.
|
||||
useEffect(() => {
|
||||
if (agentDashboardDrawerOpen) {
|
||||
closeWorkspaceBoard()
|
||||
}
|
||||
}, [agentDashboardDrawerOpen, closeWorkspaceBoard])
|
||||
// Why: a transient drag preview is not the user opening the board, so it must
|
||||
// not evict the dashboard — key on the opened state, not the rendered state.
|
||||
useEffect(() => {
|
||||
if (workspaceBoardOpen) {
|
||||
setAgentDashboardDrawerOpen(false)
|
||||
}
|
||||
}, [setAgentDashboardDrawerOpen, workspaceBoardOpen])
|
||||
|
||||
const { containerRef, onResizeStart, isResizing } = useSidebarResize<HTMLDivElement>({
|
||||
isOpen: sidebarOpen,
|
||||
width: sidebarWidth,
|
||||
@@ -217,11 +195,16 @@ function Sidebar({
|
||||
onMenuOpenChange={setWorkspaceBoardMenuOpen}
|
||||
/>
|
||||
) : null}
|
||||
{sidebarOpen && settings?.experimentalAgentDashboardPopout === true ? (
|
||||
<AgentDashboardDrawer
|
||||
leftSidebarStyle={leftSidebarStyle}
|
||||
statusBarVisible={statusBarVisible}
|
||||
/>
|
||||
{settings?.experimentalAgentDashboardPopout === true ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<AgentDashboardSidebarHost
|
||||
sidebarOpen={sidebarOpen}
|
||||
workspaceBoardOpen={workspaceBoardOpen}
|
||||
closeWorkspaceBoard={closeWorkspaceBoard}
|
||||
leftSidebarStyle={leftSidebarStyle}
|
||||
statusBarVisible={statusBarVisible}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
@@ -108,8 +108,8 @@ describe('getDefaultSettings', () => {
|
||||
})
|
||||
|
||||
it('keeps the agent dashboard popout disabled by default', () => {
|
||||
expect(getDefaultSettings('/tmp').experimentalAgentDashboardPopout).toBe(false)
|
||||
expect(getDefaultSettings('/tmp').experimentalAgentDashboardShowIdle).toBe(false)
|
||||
expect(getDefaultSettings('/tmp').experimentalAgentDashboardPopout).toBeUndefined()
|
||||
expect(getDefaultSettings('/tmp').experimentalAgentDashboardShowIdle).toBeUndefined()
|
||||
})
|
||||
|
||||
it('routes fresh Codex profiles through the real-home rollout by default', () => {})
|
||||
|
||||
@@ -357,10 +357,6 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
||||
// Why: off keeps the cosmetic overlay unmounted for users who never opt in.
|
||||
experimentalPet: false,
|
||||
experimentalActivity: false,
|
||||
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,
|
||||
|
||||
@@ -30,6 +30,9 @@ export const DASHBOARD_MAX_LABEL_LENGTH = 1_024
|
||||
* the launcher's entries rather than letting a huge fleet blank the pop-out. */
|
||||
export const DASHBOARD_MAX_LAUNCH_WORKTREES = 500
|
||||
|
||||
/** Keeps optional map-only workspace metadata bounded across the renderer bridge. */
|
||||
export const DASHBOARD_MAX_MAP_WORKSPACES = 2_000
|
||||
|
||||
/** Kept distinct from `bucket` so attention cards retain their precise dot state. */
|
||||
export type DashboardCardDotState = 'working' | 'blocked' | 'waiting' | 'done' | 'idle'
|
||||
|
||||
@@ -54,6 +57,22 @@ export type DashboardCardSubagent = {
|
||||
export type DashboardCardHostKind = 'local' | 'ssh' | 'wsl' | 'remote'
|
||||
export type DashboardCardWorkspaceKind = 'worktree' | 'folder'
|
||||
|
||||
export type DashboardWorkspace = {
|
||||
repoId: string
|
||||
worktreeId: string
|
||||
repoName: string
|
||||
worktreeName: string
|
||||
parentWorktreeId?: string
|
||||
hostKind: DashboardCardHostKind
|
||||
executionHostId: ExecutionHostId
|
||||
workspaceKind: DashboardCardWorkspaceKind
|
||||
workspaceStatusId?: string
|
||||
workspaceStatusLabel?: string
|
||||
workspaceStatusColor?: string
|
||||
hasReview?: boolean
|
||||
review?: DashboardCardReview
|
||||
}
|
||||
|
||||
export type DashboardCard = {
|
||||
/** Stable identity for React keys. */
|
||||
paneKey: string
|
||||
@@ -74,7 +93,7 @@ export type DashboardCard = {
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
leafId: string | null
|
||||
/** Direct orchestration parent when both agents are visible in this workspace. */
|
||||
/** Agent pane that spawned this agent, when both are visible. */
|
||||
parentPaneKey?: string
|
||||
/** Direct workspace parent. The map uses it only when both workspace rings are visible. */
|
||||
parentWorktreeId?: string
|
||||
@@ -150,6 +169,9 @@ export type DashboardFilterOptions = {
|
||||
export type DashboardSnapshot = {
|
||||
generatedAt: number
|
||||
cards: DashboardCard[]
|
||||
/** Active workspaces, including those without an agent card. Map-only and
|
||||
* optional for preload compatibility with older snapshot producers. */
|
||||
workspaces?: DashboardWorkspace[]
|
||||
showIdle?: boolean
|
||||
/** Available filter dimensions are store-derived so zero-card projects and
|
||||
* statuses remain selectable. Optional for preload-version compatibility. */
|
||||
@@ -166,6 +188,7 @@ export type DashboardSnapshot = {
|
||||
export const EMPTY_DASHBOARD_SNAPSHOT: DashboardSnapshot = {
|
||||
generatedAt: 0,
|
||||
cards: [],
|
||||
workspaces: [],
|
||||
filterOptions: { projects: [], workspaceStatuses: [] },
|
||||
launchableAgentsByWorktreeId: {},
|
||||
repoIconsByRepoId: {}
|
||||
|
||||
Reference in New Issue
Block a user