checkpoint agent dashboard fleet views

This commit is contained in:
Brennan Benson
2026-07-30 23:51:06 -07:00
parent 8ea8fe1a6d
commit 2e7d196074
55 changed files with 6930 additions and 115 deletions
@@ -38,8 +38,11 @@ const SNAPSHOT = {
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
parentPaneKey: 'tab-parent:leaf-parent',
repoName: 'Orca',
worktreeName: 'Dashboard',
hostKind: 'ssh',
workspaceKind: 'worktree',
workspaceStatusId: 'in-review',
workspaceStatusLabel: 'In review',
workspaceStatusColor: 'emerald',
@@ -102,6 +105,24 @@ describe('dashboard payload validation', () => {
cards: [{ ...SNAPSHOT.cards[0], subagents: [{ id: '', name: 'bad', dotState: 'idle' }] }]
})
).toBe(false)
expect(
isDashboardSnapshot({
...SNAPSHOT,
cards: [{ ...SNAPSHOT.cards[0], hostKind: 'satellite' }]
})
).toBe(false)
expect(
isDashboardSnapshot({
...SNAPSHOT,
cards: [{ ...SNAPSHOT.cards[0], workspaceKind: 'repository' }]
})
).toBe(false)
expect(
isDashboardSnapshot({
...SNAPSHOT,
cards: [{ ...SNAPSHOT.cards[0], parentPaneKey: 'x'.repeat(4_097) }]
})
).toBe(false)
})
it('accepts repo icons a pop-out can safely render, and rejects the rest', () => {
@@ -28,6 +28,8 @@ const MAX_ID_LENGTH = 4_096
const MAX_LABEL_LENGTH = DASHBOARD_MAX_LABEL_LENGTH
const DASHBOARD_BUCKETS = new Set(['attention', 'working', 'done', 'idle'])
const DASHBOARD_DOT_STATES = new Set(['working', 'blocked', 'waiting', 'done', 'idle'])
const DASHBOARD_HOST_KINDS = new Set(['local', 'ssh', 'wsl', 'remote'])
const DASHBOARD_WORKSPACE_KINDS = new Set(['worktree', 'folder'])
const DASHBOARD_REVIEW_STATES = new Set(['open', 'closed', 'merged', 'draft'])
const DASHBOARD_HOST_PLATFORMS = new Set([
'aix',
@@ -264,8 +266,14 @@ function isDashboardCard(value: unknown): boolean {
isBoundedString(card.worktreeId, MAX_ID_LENGTH) &&
isBoundedString(card.tabId, MAX_ID_LENGTH) &&
(card.leafId === null || isBoundedString(card.leafId, MAX_ID_LENGTH)) &&
isOptionalBoundedString(card.parentPaneKey, MAX_ID_LENGTH) &&
isBoundedString(card.repoName, MAX_LABEL_LENGTH, true) &&
isBoundedString(card.worktreeName, MAX_LABEL_LENGTH, true) &&
(card.hostKind === undefined ||
(typeof card.hostKind === 'string' && DASHBOARD_HOST_KINDS.has(card.hostKind))) &&
(card.workspaceKind === undefined ||
(typeof card.workspaceKind === 'string' &&
DASHBOARD_WORKSPACE_KINDS.has(card.workspaceKind))) &&
isOptionalBoundedString(card.workspaceStatusId, MAX_ID_LENGTH) &&
isOptionalBoundedString(card.workspaceStatusLabel, MAX_LABEL_LENGTH) &&
isOptionalBoundedString(card.workspaceStatusColor, MAX_ID_LENGTH) &&
@@ -231,6 +231,12 @@ describe('createOrFocusDashboardPopout', () => {
expect(options).toEqual({ search: 'view=kanban' })
})
it('opens on the current dashboard view by default', () => {
createOrFocusDashboardPopout(makeStore() as never)
expect(instances[0].loadFile.mock.calls[0][1]).toEqual({ search: 'view=board' })
})
it('loads the dev server URL with the requested view when in dev', () => {
isMock.dev = true
vi.stubEnv('ELECTRON_RENDERER_URL', RENDERER_URL)
+1 -1
View File
@@ -18,7 +18,7 @@ const MIN_WIDTH = 480
const MIN_HEIGHT = 360
const DEFAULT_WIDTH = 960
const DEFAULT_HEIGHT = 720
const DEFAULT_VIEW = 'kanban'
const DEFAULT_VIEW = 'board'
const DASHBOARD_POPOUT_PARTITION = 'orca-dashboard-popout'
// Why: singleton — the dashboard is a companion surface, so a second "Pop Out"
@@ -0,0 +1,105 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { AgentCellMap } from './AgentCellMap'
const NOW = 1_000_000
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
return {
paneKey: 'pane-1',
ptyId: 'pty-1',
agentType: 'codex',
bucket: 'working',
dotState: 'working',
task: 'Build cells',
repoId: 'repo-1',
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
repoName: 'Orca',
worktreeName: 'Cell map',
conversationName: 'Agent alpha',
startedAt: 1,
finishedAt: null,
stateChangedAt: 1,
unseen: false,
...overrides
}
}
function renderCells(cards: DashboardCard[], onOpenTerminal = vi.fn()): ReturnType<typeof render> {
return render(
<TooltipProvider>
<AgentCellMap cards={cards} now={NOW} onOpenTerminal={onOpenTerminal} />
</TooltipProvider>
)
}
afterEach(cleanup)
describe('AgentCellMap', () => {
it('groups fixed-size agents into project and worktree cells', () => {
renderCells([
card(),
card({
paneKey: 'pane-2',
conversationName: 'Agent beta',
dotState: 'done',
bucket: 'done'
}),
card({
paneKey: 'pane-3',
repoId: 'repo-2',
repoName: 'Mobile',
worktreeId: 'worktree-2',
worktreeName: 'Mobile cells',
conversationName: 'Mobile agent'
})
])
expect(screen.getAllByTestId('agent-cell-project')).toHaveLength(2)
expect(screen.getAllByTestId('agent-cell-worktree')).toHaveLength(2)
expect(screen.getByText('Cell map')).toBeInTheDocument()
expect(screen.getByText('Mobile cells')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Agent alpha, Working' })).toHaveClass(
'fleet-status-working'
)
expect(screen.getByRole('button', { name: 'Agent beta, Done' })).toHaveClass(
'fleet-status-done'
)
})
it('opens the shared terminal flow from an agent node', () => {
const onOpenTerminal = vi.fn()
const agent = card()
renderCells([agent], onOpenTerminal)
fireEvent.click(screen.getByRole('button', { name: 'Agent alpha, Working' }))
expect(onOpenTerminal).toHaveBeenCalledWith(agent, 'right')
})
it('shows the most recent response age instead of an agent count', () => {
renderCells([
card({
paneKey: 'pane-1',
bucket: 'done',
dotState: 'done',
finishedAt: NOW - 10 * 60_000,
stateChangedAt: NOW - 10 * 60_000
}),
card({
paneKey: 'pane-2',
conversationName: 'Agent beta',
startedAt: NOW - 30 * 60_000,
stateChangedAt: NOW - 2 * 60_000
})
])
expect(screen.getByText('2m')).toBeInTheDocument()
})
})
@@ -0,0 +1,275 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
import { cn } from '@/lib/utils'
import type { DashboardBucket, DashboardCard } from '../../../../shared/dashboard-snapshot'
import './agent-cell-map.css'
type AgentCellMapProps = {
cards: DashboardCard[]
now: number
className?: string
selectedPaneKey?: string | null
onOpenTerminal: (card: DashboardCard, side: 'left' | 'right') => void
}
type WorktreeCell = {
id: string
name: string
cards: DashboardCard[]
lastResponseAt: number
}
type ProjectCell = {
id: string
name: string
worktrees: WorktreeCell[]
agentCount: number
}
const BUCKET_PRIORITY: Record<DashboardBucket, number> = {
attention: 0,
working: 1,
done: 2,
idle: 3
}
function compareCards(a: DashboardCard, b: DashboardCard): number {
return (
BUCKET_PRIORITY[a.bucket] - BUCKET_PRIORITY[b.bucket] || b.stateChangedAt - a.stateChangedAt
)
}
function responseTimestamp(card: DashboardCard): number {
if (card.finishedAt && card.finishedAt > 0) {
return card.finishedAt
}
return card.stateChangedAt > 0 ? card.stateChangedAt : card.startedAt
}
function formatResponseAge(timestamp: number, now: number): string {
if (timestamp <= 0) {
return '—'
}
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000))
if (seconds < 60) {
return translate('dashboardPopout.card.time.justNow', 'just now')
}
const minutes = Math.floor(seconds / 60)
if (minutes < 60) {
return translate('dashboardPopout.card.time.minutes', '{{count}}m', { count: minutes })
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return translate('dashboardPopout.card.time.hours', '{{count}}h', { count: hours })
}
return translate('dashboardPopout.card.time.days', '{{count}}d', {
count: Math.floor(hours / 24)
})
}
function buildProjectCells(cards: DashboardCard[]): ProjectCell[] {
const projects = new Map<string, DashboardCard[]>()
for (const card of cards) {
projects.set(card.repoId, [...(projects.get(card.repoId) ?? []), card])
}
return [...projects.entries()]
.map(([id, projectCards]) => {
const worktrees = new Map<string, DashboardCard[]>()
for (const card of projectCards) {
worktrees.set(card.worktreeId, [...(worktrees.get(card.worktreeId) ?? []), card])
}
return {
id,
name: projectCards[0]?.repoName ?? id,
agentCount: projectCards.length,
worktrees: [...worktrees.entries()]
.map(([worktreeId, worktreeCards]) => ({
id: worktreeId,
name: worktreeCards[0]?.worktreeName ?? worktreeId,
cards: [...worktreeCards].sort(compareCards),
lastResponseAt: Math.max(...worktreeCards.map(responseTimestamp))
}))
.sort(
(a, b) =>
compareCards(a.cards[0], b.cards[0]) ||
b.cards.length - a.cards.length ||
a.name.localeCompare(b.name)
)
}
})
.sort((a, b) => b.agentCount - a.agentCount || a.name.localeCompare(b.name))
}
function worktreeTone(cards: DashboardCard[]): string {
if (cards.some((card) => card.bucket === 'attention')) {
return 'has-attention'
}
if (cards.some((card) => card.bucket === 'working')) {
return 'has-working'
}
return cards.every((card) => card.dotState === 'done') ? 'has-done' : ''
}
function worktreeSize(agentCount: number): string {
if (agentCount >= 9) {
return 'is-large'
}
return agentCount >= 5 ? 'is-medium' : ''
}
function agentName(card: DashboardCard): string {
return card.conversationName ?? formatAgentTypeLabel(card.agentType)
}
function AgentCellNode({
card,
selected,
setNodeRef,
onOpenTerminal
}: {
card: DashboardCard
selected: boolean
setNodeRef: (node: HTMLButtonElement | null) => void
onOpenTerminal: (card: DashboardCard, side: 'left' | 'right') => void
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
ref={setNodeRef}
type="button"
className={`agent-cell-node fleet-status-${card.dotState}`}
aria-label={`${agentName(card)}, ${agentStateLabel(card.dotState)}`}
aria-pressed={selected}
data-selected={selected}
onClick={(event) => {
const bounds = event.currentTarget.getBoundingClientRect()
const side = bounds.left + bounds.width / 2 <= window.innerWidth / 2 ? 'right' : 'left'
onOpenTerminal(card, side)
}}
>
<AgentIcon agent={agentTypeToIconAgent(card.agentType)} size={18} />
<span className="agent-cell-node-state">
<AgentStateDot state={card.dotState} size="md" />
</span>
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
<span className="font-medium">{agentName(card)}</span>
<span className="ml-1.5 text-muted-foreground">{agentStateLabel(card.dotState)}</span>
</TooltipContent>
</Tooltip>
)
}
export function AgentCellMap({
cards,
now,
className,
selectedPaneKey = null,
onOpenTerminal
}: AgentCellMapProps): React.JSX.Element {
const projects = useMemo(() => buildProjectCells(cards), [cards])
const nodeRefs = useRef(new Map<string, HTMLButtonElement>())
const setNodeRef = useCallback((paneKey: string, node: HTMLButtonElement | null) => {
if (node) {
nodeRefs.current.set(paneKey, node)
} else {
nodeRefs.current.delete(paneKey)
}
}, [])
useEffect(() => {
if (!selectedPaneKey) {
return
}
const behavior = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
? 'auto'
: 'smooth'
nodeRefs.current
.get(selectedPaneKey)
?.scrollIntoView?.({ behavior, block: 'center', inline: 'center' })
}, [selectedPaneKey])
if (projects.length === 0) {
return (
<div
className={cn(
'grid min-h-0 flex-1 place-items-center text-xs text-muted-foreground',
className
)}
>
{translate('dashboardPopout.rings.empty', 'No agents match the current filters.')}
</div>
)
}
return (
<div
className={cn('agent-cell-map scrollbar-sleek min-h-0 flex-1 overflow-auto p-3', className)}
>
<div className="agent-cell-projects">
{projects.map((project) => (
<section
key={project.id}
className="agent-cell-project"
style={{ flexGrow: Math.sqrt(project.agentCount) }}
data-testid="agent-cell-project"
>
<header className="flex items-baseline gap-2 border-b border-border px-3 py-2.5">
<h2 className="truncate text-[13px] font-semibold">{project.name}</h2>
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-muted-foreground">
{translate(
'dashboardPopout.rings.projectCount',
'{{agents}} agents · {{workspaces}} workspaces',
{
agents: project.agentCount,
workspaces: project.worktrees.length
}
)}
</span>
</header>
<div className="agent-cell-worktrees">
{project.worktrees.map((worktree) => (
<article
key={worktree.id}
className={cn(
'agent-cell-worktree',
worktreeTone(worktree.cards),
worktreeSize(worktree.cards.length),
worktree.cards.some((card) => card.paneKey === selectedPaneKey) && 'is-selected'
)}
data-testid="agent-cell-worktree"
>
<header className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-xs font-medium" title={worktree.name}>
{worktree.name}
</h3>
<span className="ml-auto shrink-0 text-[10px] tabular-nums text-muted-foreground">
{formatResponseAge(worktree.lastResponseAt, now)}
</span>
</header>
<div className="flex flex-wrap content-start gap-1.5">
{worktree.cards.map((card) => (
<AgentCellNode
key={card.paneKey}
card={card}
selected={card.paneKey === selectedPaneKey}
setNodeRef={(node) => setNodeRef(card.paneKey, node)}
onOpenTerminal={onOpenTerminal}
/>
))}
</div>
</article>
))}
</div>
</section>
))}
</div>
</div>
)
}
@@ -25,6 +25,7 @@ import {
toggleDashboardFilter
} from './agent-board-filtering'
import { AgentDashboardFilterChips } from './AgentDashboardFilterChips'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
type FilterOption = { id: string; label: string; count: number; color?: string }
@@ -36,6 +37,7 @@ type AgentDashboardToolbarProps = {
onQueryChange: (query: string) => void
filters: DashboardFilters
onFiltersChange: (filters: DashboardFilters) => void
searchInputRef: React.RefObject<HTMLInputElement | null>
}
function countBy(
@@ -134,8 +136,10 @@ export function AgentDashboardToolbar({
query,
onQueryChange,
filters,
onFiltersChange
onFiltersChange,
searchInputRef
}: AgentDashboardToolbarProps): React.JSX.Element {
const isMac = navigator.userAgent.includes('Mac')
const projects = projectOptions(cards, filterOptions?.projects)
const statuses = workspaceStatusOptions(cards, filterOptions?.workspaceStatuses)
const reviewCounts = countBy(
@@ -168,6 +172,7 @@ export function AgentDashboardToolbar({
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={translate(
@@ -175,7 +180,7 @@ export function AgentDashboardToolbar({
'Search worktree, project, or agent…'
)}
aria-label={translate('dashboardPopout.search.label', 'Search agents')}
className="h-7 bg-muted/55 pr-7 pl-7 text-xs"
className="h-7 bg-muted/55 pr-16 pl-7 text-xs"
/>
{query ? (
<Button
@@ -187,7 +192,14 @@ export function AgentDashboardToolbar({
>
<X className="size-3" />
</Button>
) : null}
) : (
<ShortcutKeyCombo
keys={[isMac ? '⌘' : 'Ctrl', 'K']}
className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2"
keyCapClassName="min-w-4 px-1 py-0 text-[9px] shadow-none"
separatorClassName="text-[9px] text-muted-foreground"
/>
)}
</div>
{query || activeCount > 0 ? (
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
@@ -0,0 +1,344 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { AgentFleetRings } from './AgentFleetRings'
const NOW = 2_000_000_000
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
return {
paneKey: 'pane-1',
ptyId: 'pty-1',
agentType: 'codex',
bucket: 'working',
dotState: 'working',
task: 'Build rings',
repoId: 'repo-1',
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
repoName: 'Orca',
worktreeName: 'Fleet rings',
conversationName: 'Agent alpha',
startedAt: NOW - 10 * 60_000,
finishedAt: null,
stateChangedAt: NOW - 1_000,
unseen: false,
hostKind: 'local',
workspaceKind: 'worktree',
...overrides
}
}
function renderRings(
cards: DashboardCard[],
{
reviewedPaneKeys = new Set<string>(),
pinnedPaneKeys = new Set<string>(),
onMarkReviewed = vi.fn(),
onOpenTerminal = vi.fn(),
selectedPaneKey = null,
compact = false
}: {
reviewedPaneKeys?: ReadonlySet<string>
pinnedPaneKeys?: ReadonlySet<string>
onMarkReviewed?: (cards: DashboardCard[]) => void
onOpenTerminal?: (card: DashboardCard, side: 'left' | 'right') => void
selectedPaneKey?: string | null
compact?: boolean
} = {}
): ReturnType<typeof render> {
return render(
<AgentFleetRings
cards={cards}
now={NOW}
reviewedPaneKeys={reviewedPaneKeys}
pinnedPaneKeys={pinnedPaneKeys}
onMarkReviewed={onMarkReviewed}
onOpenTerminal={onOpenTerminal}
selectedPaneKey={selectedPaneKey}
compact={compact}
/>
)
}
describe('AgentFleetRings', () => {
const originalUserAgent = navigator.userAgent
let boundsSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Linux' })
boundsSpy = vi
.spyOn(Element.prototype, 'getBoundingClientRect')
.mockImplementation(function getBounds(this: Element) {
if (this.classList.contains('fleet-rings-canvas') || this instanceof SVGSVGElement) {
return {
x: 0,
y: 0,
left: 0,
top: 0,
right: 400,
bottom: 300,
width: 400,
height: 300,
toJSON: () => ({})
}
}
return {
x: 0,
y: 0,
left: 0,
top: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
toJSON: () => ({})
}
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
vi.unstubAllGlobals()
boundsSpy.mockRestore()
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value: originalUserAgent
})
})
it('renders separate provider and app-state icons inside status-glow nodes', () => {
const finished = card({
paneKey: 'done',
conversationName: 'Finished agent',
bucket: 'done',
dotState: 'done',
finishedAt: NOW - 60_000,
unseen: true
})
renderRings([card(), finished])
const workingNode = screen.getByRole('button', { name: /Agent alpha/ })
const doneNode = screen.getByRole('button', { name: /Finished agent/ })
expect(workingNode).toHaveClass('fleet-status-working')
expect(doneNode).toHaveClass('fleet-status-done')
expect(workingNode.querySelector('.fleet-agent-icon svg')).toBeInTheDocument()
expect(workingNode.querySelector('[data-agent-spinner]')).toBeInTheDocument()
expect(doneNode.querySelector('.fleet-agent-icon svg')).toBeInTheDocument()
expect(doneNode.querySelector('.fleet-agent-state svg')).toBeInTheDocument()
})
it('connects orchestrated workers beneath their visible parent', () => {
const parent = card({ paneKey: 'parent', conversationName: 'Coordinator' })
const child = card({
paneKey: 'child',
parentPaneKey: 'parent',
conversationName: 'Worker'
})
const orphan = card({
paneKey: 'orphan',
parentPaneKey: 'filtered-parent',
conversationName: 'Orphaned worker'
})
const { container } = renderRings([parent, child, orphan])
const links = container.querySelectorAll('[data-fleet-agent-lineage-link]')
expect(screen.getByRole('button', { name: /Coordinator/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^Worker/ })).toBeInTheDocument()
expect(links).toHaveLength(1)
expect(links[0]).toHaveAttribute('data-parent-pane-key', 'parent')
expect(links[0]).toHaveAttribute('data-child-pane-key', 'child')
})
it('opens the shared dashboard terminal dialog when an agent is clicked', () => {
const onOpenTerminal = vi.fn()
const agent = card()
renderRings([agent], { onOpenTerminal })
fireEvent.click(screen.getByRole('button', { name: /Agent alpha/ }))
expect(onOpenTerminal).toHaveBeenCalledWith(agent, 'right')
})
it('keeps a selected node visible while compacting around an adjacent terminal', () => {
renderRings([card()], { selectedPaneKey: 'pane-1', compact: true })
expect(screen.getByRole('button', { name: /Agent alpha/ })).toHaveClass('is-selected')
expect(screen.queryByText('Focus view')).not.toBeInTheDocument()
expect(screen.queryByRole('group', { name: 'Host filter' })).not.toBeInTheDocument()
})
it('increases map label scale when users zoom out', () => {
const { container } = renderRings([card()])
const labelGroup = container.querySelector('.fleet-worktree-label')?.parentElement
const initialScale = Number(
labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1]
)
const readsBeforeZoom = boundsSpy.mock.calls.length
fireEvent.click(screen.getByRole('button', { name: 'Zoom out' }))
const zoomedScale = Number(
labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1]
)
expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeZoom)
expect(zoomedScale).toBeGreaterThan(initialScale)
})
it('avoids idle pointer layout reads and batches active viewport updates by frame', () => {
const frames: FrameRequestCallback[] = []
const requestFrame = vi.fn((callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
vi.stubGlobal('requestAnimationFrame', requestFrame)
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const { container } = renderRings([card()])
const svg = container.querySelector<SVGSVGElement>('.fleet-rings-canvas > svg')!
Object.assign(svg, {
setPointerCapture: vi.fn(),
releasePointerCapture: vi.fn()
})
const readsBeforeHover = boundsSpy.mock.calls.length
fireEvent.pointerMove(svg, { pointerId: 1, clientX: 20, clientY: 20 })
expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeHover)
fireEvent.pointerDown(svg, { pointerId: 1, clientX: 20, clientY: 20 })
expect(svg.setPointerCapture).toHaveBeenCalledWith(1)
const readsAfterPointerDown = boundsSpy.mock.calls.length
const initialViewBox = svg.getAttribute('viewBox')
fireEvent.pointerMove(svg, { pointerId: 1, clientX: 30, clientY: 20 })
fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 20 })
expect(boundsSpy).toHaveBeenCalledTimes(readsAfterPointerDown)
expect(requestFrame).toHaveBeenCalledOnce()
expect(svg).toHaveAttribute('viewBox', initialViewBox)
act(() => frames.shift()?.(0))
expect(svg.getAttribute('viewBox')).not.toBe(initialViewBox)
requestFrame.mockClear()
const readsBeforeWheel = boundsSpy.mock.calls.length
const wheel = (): void => {
const event = new Event('wheel', { bubbles: true, cancelable: true })
Object.defineProperties(event, {
deltaY: { value: -10 },
clientX: { value: 100 },
clientY: { value: 100 }
})
fireEvent(svg, event)
}
wheel()
wheel()
expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeWheel + 1)
expect(requestFrame).toHaveBeenCalledOnce()
})
it('keeps active labels visible and progressively discloses quiet labels', () => {
const quiet = card({
paneKey: 'done',
worktreeId: 'quiet-worktree',
worktreeName: 'Quiet result',
conversationName: 'Finished agent',
bucket: 'done',
dotState: 'done',
finishedAt: NOW - 60_000,
unseen: true
})
const { container } = renderRings([card(), quiet])
const labels = [...container.querySelectorAll('.fleet-worktree-label')]
const activeGroup = labels.find((label) => label.textContent === 'Fleet rings')?.parentElement
const quietGroup = labels.find((label) => label.textContent === 'Quiet result')?.parentElement
expect(activeGroup).toHaveClass('is-visible')
expect(quietGroup).not.toHaveClass('is-visible')
})
it('defaults to active agents and finished results that still need review', () => {
const recentReviewed = card({
paneKey: 'reviewed',
conversationName: 'Reviewed result',
bucket: 'done',
dotState: 'done',
finishedAt: NOW - 60_000
})
const newResult = card({
paneKey: 'new',
conversationName: 'New result',
bucket: 'done',
dotState: 'done',
finishedAt: NOW - 2 * 60_000,
unseen: true
})
renderRings([card(), recentReviewed, newResult], {
reviewedPaneKeys: new Set(['reviewed'])
})
expect(screen.getByRole('button', { name: /Agent alpha/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /New result/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Reviewed result/ })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /^Last 24 hours/ }))
expect(screen.getByRole('button', { name: /Reviewed result/ })).toBeInTheDocument()
})
it('lets users hide states and projects independently', () => {
renderRings([
card(),
card({
paneKey: 'other',
conversationName: 'Other project',
repoId: 'repo-2',
repoName: 'Mobile'
})
])
fireEvent.click(screen.getByRole('button', { name: /^Working/ }))
expect(screen.queryByRole('button', { name: /Agent alpha/ })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /^Working/ }))
fireEvent.click(screen.getByRole('button', { name: /^Mobile/ }))
expect(screen.getByRole('button', { name: /Agent alpha/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Other project/ })).not.toBeInTheDocument()
})
it('bulk-reviews only visible unreviewed results', () => {
const onMarkReviewed = vi.fn()
const result = card({
paneKey: 'done',
conversationName: 'Finished agent',
bucket: 'done',
dotState: 'done',
finishedAt: NOW - 60_000,
unseen: true
})
renderRings([card(), result], { onMarkReviewed })
fireEvent.click(screen.getByRole('button', { name: 'Mark visible results reviewed' }))
expect(onMarkReviewed).toHaveBeenCalledWith([result])
})
it('keeps exact review nodes visible and aggregates quiet history only in broad scopes', () => {
const results = Array.from({ length: 5 }, (_, index) =>
card({
paneKey: `done-${index}`,
conversationName: `Result ${index}`,
bucket: 'done',
dotState: 'done',
finishedAt: NOW - 60_000,
unseen: true
})
)
const { container } = renderRings(results)
expect(container.querySelectorAll('[data-fleet-agent]')).toHaveLength(5)
expect(container.querySelectorAll('.fleet-aggregate-node')).toHaveLength(0)
fireEvent.click(screen.getByRole('button', { name: /^All finished/ }))
expect(container.querySelectorAll('[data-fleet-agent]')).toHaveLength(0)
expect(container.querySelectorAll('.fleet-aggregate-node')).toHaveLength(1)
})
})
@@ -0,0 +1,240 @@
import { useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import type { DashboardCard, DashboardCardHostKind } from '../../../../shared/dashboard-snapshot'
import { FleetRingsCanvas, type FleetRingsCanvasHandle } from './FleetRingsCanvas'
import { FleetRingsFocusRail } from './FleetRingsFocusRail'
import {
countFleetFocusCards,
filterFleetFocusCards,
type FleetFinishedScope,
type FleetFocusState,
type FleetHostFilter
} from './fleet-rings-focus-filter'
import { updateFleetRingsLayout, type FleetRingsLayoutCache } from './fleet-rings-layout'
import './fleet-rings.css'
type AgentFleetRingsProps = {
cards: DashboardCard[]
now: number
className?: string
compact?: boolean
selectedPaneKey?: string | null
pinnedPaneKeys: ReadonlySet<string>
reviewedPaneKeys: ReadonlySet<string>
onMarkReviewed: (cards: DashboardCard[]) => void
onOpenTerminal: (card: DashboardCard, side: 'left' | 'right') => void
}
const HOST_FILTERS: FleetHostFilter[] = ['all', 'local', 'ssh', 'wsl', 'remote']
const DEFAULT_STATES: FleetFocusState[] = ['attention', 'working', 'finished']
function hostFilterLabel(filter: FleetHostFilter): string {
switch (filter) {
case 'all':
return translate('dashboardPopout.rings.host.all', 'All hosts')
case 'local':
return translate('dashboardPopout.rings.host.local', 'Local')
case 'ssh':
return translate('dashboardPopout.rings.host.ssh', 'SSH')
case 'wsl':
return translate('dashboardPopout.rings.host.wsl', 'WSL')
case 'remote':
return translate('dashboardPopout.rings.host.remote', 'Remote')
}
}
export function AgentFleetRings({
cards,
now,
className,
compact = false,
selectedPaneKey = null,
pinnedPaneKeys,
reviewedPaneKeys,
onMarkReviewed,
onOpenTerminal
}: AgentFleetRingsProps): React.JSX.Element {
const canvasRef = useRef<FleetRingsCanvasHandle>(null)
const layoutCacheRef = useRef<FleetRingsLayoutCache | null>(null)
const [hostFilter, setHostFilter] = useState<FleetHostFilter>('all')
const [enabledStates, setEnabledStates] = useState<Set<FleetFocusState>>(
() => new Set(DEFAULT_STATES)
)
const [finishedScope, setFinishedScope] = useState<FleetFinishedScope>('review')
const [hiddenProjectIds, setHiddenProjectIds] = useState<Set<string>>(() => new Set())
const hostCounts = useMemo(() => {
const counts: Record<DashboardCardHostKind, number> = {
local: 0,
ssh: 0,
wsl: 0,
remote: 0
}
for (const card of cards) {
counts[card.hostKind ?? 'local'] += 1
}
return counts
}, [cards])
const projects = useMemo(() => {
const byId = new Map<string, { id: string; name: string; count: number }>()
for (const card of cards) {
const current = byId.get(card.repoId)
if (current) {
current.count += 1
} else {
byId.set(card.repoId, { id: card.repoId, name: card.repoName, count: 1 })
}
}
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name))
}, [cards])
const counts = useMemo(
() => countFleetFocusCards(cards, now, reviewedPaneKeys),
[cards, now, reviewedPaneKeys]
)
const visibleCards = useMemo(
() =>
filterFleetFocusCards({
cards,
enabledStates,
finishedScope,
hostFilter,
hiddenProjectIds,
pinnedPaneKeys,
reviewedPaneKeys,
now
}),
[
cards,
enabledStates,
finishedScope,
hiddenProjectIds,
hostFilter,
now,
pinnedPaneKeys,
reviewedPaneKeys
]
)
const reviewableVisibleCards = useMemo(
() =>
visibleCards.filter(
(card) => card.dotState === 'done' && !reviewedPaneKeys.has(card.paneKey)
),
[reviewedPaneKeys, visibleCards]
)
const pinnedCount = useMemo(
() => cards.filter((card) => pinnedPaneKeys.has(card.paneKey)).length,
[cards, pinnedPaneKeys]
)
const layout = useMemo(() => {
const next = updateFleetRingsLayout(layoutCacheRef.current, visibleCards, now)
layoutCacheRef.current = next.cache
return next.layout
}, [visibleCards, now])
const toggleState = (state: FleetFocusState): void => {
setEnabledStates((current) => {
const next = new Set(current)
if (next.has(state)) {
next.delete(state)
} else {
next.add(state)
}
return next
})
}
const toggleProject = (projectId: string): void => {
setHiddenProjectIds((current) => {
const next = new Set(current)
if (next.has(projectId)) {
next.delete(projectId)
} else {
next.add(projectId)
}
return next
})
}
const showAll = (): void => {
setEnabledStates(new Set(DEFAULT_STATES))
setFinishedScope('all')
setHiddenProjectIds(new Set())
setHostFilter('all')
}
return (
<section className={cn('flex min-h-0 flex-1', className)}>
{!compact ? (
<FleetRingsFocusRail
counts={counts}
enabledStates={enabledStates}
finishedScope={finishedScope}
hiddenProjectIds={hiddenProjectIds}
pinnedCount={pinnedCount}
projects={projects}
reviewableVisibleCards={reviewableVisibleCards}
totalCount={cards.length}
visibleCount={visibleCards.length}
onFinishedScopeChange={setFinishedScope}
onFit={() => canvasRef.current?.fit()}
onMarkReviewed={onMarkReviewed}
onProjectToggle={toggleProject}
onShowAll={showAll}
onStateToggle={toggleState}
/>
) : null}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex min-h-12 shrink-0 items-center gap-3 border-b border-border px-3 py-2">
<span className="min-w-0">
<span className="block text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate('dashboardPopout.rings.liveContainmentMap', 'Live containment map')}
</span>
<strong className="block truncate text-xs">
{translate(
'dashboardPopout.rings.focus.canvasSummary',
'Focus · {{shown}} of {{total}} agents',
{
shown: visibleCards.length,
total: cards.length
}
)}
</strong>
</span>
{!compact ? (
<div
className="ml-auto flex items-center gap-0.5 rounded-md border border-border p-0.5"
role="group"
aria-label={translate('dashboardPopout.rings.hostFilter', 'Host filter')}
>
{HOST_FILTERS.filter((option) => option === 'all' || hostCounts[option] > 0).map(
(option) => (
<Button
key={option}
type="button"
variant="ghost"
size="xs"
aria-pressed={hostFilter === option}
onClick={() => setHostFilter(option)}
className={cn(
'h-6 px-2 text-[10px]',
hostFilter === option && 'bg-accent text-accent-foreground'
)}
>
{hostFilterLabel(option)}
</Button>
)
)}
</div>
) : null}
</header>
<FleetRingsCanvas
key={layout.projects.length === 0 ? 'empty' : 'fleet'}
ref={canvasRef}
layout={layout}
selectedPaneKey={selectedPaneKey}
allowAggregation={finishedScope !== 'review'}
onSelectAgent={onOpenTerminal}
/>
</div>
</section>
)
}
@@ -41,18 +41,72 @@ vi.mock('./AgentKanbanCard', () => ({
vi.mock('./AgentTerminalDialog', () => ({
AgentTerminalDialog: ({
card,
onOpenChange
onOpenChange,
reviewed,
pinned,
onMarkReviewed,
onTogglePinned
}: {
card: DashboardCard | null
onOpenChange: (open: boolean) => void
reviewed?: boolean
pinned?: boolean
onMarkReviewed?: (card: DashboardCard) => void
onTogglePinned?: (card: DashboardCard) => void
}) => (
<div
data-testid="terminal-dialog"
data-open={card !== null}
data-bucket={card?.bucket}
data-pty-id={card?.ptyId ?? undefined}
data-reviewed={reviewed}
data-pinned={pinned}
>
<button data-testid="terminal-dialog-close" onClick={() => onOpenChange(false)} />
{card && onMarkReviewed ? (
<button data-testid="terminal-dialog-review" onClick={() => onMarkReviewed(card)} />
) : null}
{card && onTogglePinned ? (
<button data-testid="terminal-dialog-pin" onClick={() => onTogglePinned(card)} />
) : null}
</div>
),
AgentTerminalPanel: ({
card,
onOpenChange,
reviewed,
pinned,
onMarkReviewed,
onTogglePinned
}: {
card: DashboardCard | null
onOpenChange: (open: boolean) => void
reviewed?: boolean
pinned?: boolean
onMarkReviewed?: (card: DashboardCard) => void
onTogglePinned?: (card: DashboardCard) => void
}) => (
<div
data-testid="terminal-panel"
data-pty-id={card?.ptyId ?? undefined}
data-reviewed={reviewed}
data-pinned={pinned}
>
<button data-testid="terminal-panel-close" onClick={() => onOpenChange(false)} />
{card && onMarkReviewed ? (
<button
data-testid="terminal-panel-review"
onClick={() => {
onMarkReviewed(card)
if (!pinned) {
onOpenChange(false)
}
}}
/>
) : null}
{card && onTogglePinned ? (
<button data-testid="terminal-panel-pin" onClick={() => onTogglePinned(card)} />
) : null}
</div>
)
}))
@@ -96,6 +150,7 @@ const ackAgent = vi.fn(async () => {})
describe('AgentKanbanBoard', () => {
beforeEach(async () => {
await i18n.changeLanguage('en')
localStorage.clear()
// The board relays seen-acks through the dashboard preload API.
;(window as unknown as { api: unknown }).api = { dashboard: { ackAgent } }
})
@@ -112,6 +167,66 @@ describe('AgentKanbanBoard', () => {
expect(headers.map((h) => h.textContent)).toEqual(['Needs You', 'Working', 'Done'])
})
it('keeps board, rings, and cells available as separate views', () => {
renderBoard([])
fireEvent.click(screen.getByRole('button', { name: 'Agent Rings' }))
expect(screen.getByText('Live containment map')).toBeInTheDocument()
expect(screen.getByText('Focus view')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Orchestrate' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Dashboard' }))
expect(screen.getByText('Needs You')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Orchestrate' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Cells' }))
expect(screen.getByRole('button', { name: 'Cells' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByText('No agents match the current filters.')).toBeInTheDocument()
})
it('keeps the selected cell map visible beside its terminal panel', () => {
const agent = card({ paneKey: 'cell-agent', conversationName: 'Cell agent' })
render(<AgentKanbanBoard snapshot={{ generatedAt: 1, cards: [agent] }} initialView="cells" />)
fireEvent.click(screen.getByRole('button', { name: 'Cell agent, Working' }))
expect(screen.getByTestId('agent-cell-project')).toBeInTheDocument()
expect(screen.getByTestId('terminal-panel')).toHaveAttribute('data-pty-id', 'p1')
expect(screen.getByRole('button', { name: 'Cell agent, Working' })).toHaveAttribute(
'aria-pressed',
'true'
)
expect(screen.queryByTestId('terminal-dialog')).not.toBeInTheDocument()
})
it('keeps the selected ring map visible beside its terminal panel', () => {
const agent = card({ paneKey: 'ring-agent', conversationName: 'Ring agent' })
render(<AgentKanbanBoard snapshot={{ generatedAt: 1, cards: [agent] }} initialView="rings" />)
fireEvent.click(screen.getByRole('button', { name: /Ring agent/ }))
expect(screen.getByLabelText('Nested project, workspace, and agent map')).toBeInTheDocument()
expect(screen.getByTestId('terminal-panel')).toHaveAttribute('data-pty-id', 'p1')
expect(screen.getByRole('button', { name: /Ring agent/ })).toHaveClass('is-selected')
expect(screen.queryByText('Focus view')).not.toBeInTheDocument()
expect(screen.queryByTestId('terminal-dialog')).not.toBeInTheDocument()
})
it('focuses search with Ctrl+K without taking focus from response fields', () => {
renderBoard([])
const search = screen.getByLabelText('Search agents')
fireEvent.keyDown(document.body, { key: 'k', ctrlKey: true })
expect(search).toHaveFocus()
const response = document.createElement('textarea')
document.body.append(response)
response.focus()
fireEvent.keyDown(response, { key: 'k', ctrlKey: true })
expect(response).toHaveFocus()
response.remove()
})
it('places cards in their bucket column and counts them', () => {
renderBoard([
card({ bucket: 'attention', worktreeName: 'a1' }),
@@ -301,4 +416,61 @@ describe('AgentKanbanBoard', () => {
)
expect(ackAgent).toHaveBeenCalledWith('pk-ack')
})
it('keeps a newly opened result in Focus until it is explicitly reviewed', () => {
const fresh = card({
paneKey: 'fresh-result',
bucket: 'done',
dotState: 'done',
conversationName: 'Fresh result',
finishedAt: 900,
unseen: true
})
const old = card({
paneKey: 'old-result',
bucket: 'done',
dotState: 'done',
conversationName: 'Old result',
finishedAt: 800,
unseen: false
})
const view = render(
<AgentKanbanBoard snapshot={{ generatedAt: 1, cards: [fresh, old] }} initialView="rings" />
)
expect(screen.getByRole('button', { name: /Fresh result/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Old result/ })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Fresh result/ }))
expect(screen.getByTestId('terminal-panel')).toHaveAttribute('data-reviewed', 'false')
view.rerender(
<AgentKanbanBoard
snapshot={{ generatedAt: 2, cards: [{ ...fresh, unseen: false }, old] }}
initialView="rings"
/>
)
expect(screen.getByRole('button', { name: /Fresh result/ })).toBeInTheDocument()
view.unmount()
const reopened = render(
<AgentKanbanBoard
snapshot={{ generatedAt: 3, cards: [{ ...fresh, unseen: false }, old] }}
initialView="rings"
/>
)
expect(screen.getByRole('button', { name: /Fresh result/ })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Fresh result/ }))
fireEvent.click(screen.getByTestId('terminal-panel-review'))
expect(screen.queryByRole('button', { name: /Fresh result/ })).not.toBeInTheDocument()
expect(screen.queryByTestId('terminal-panel')).not.toBeInTheDocument()
reopened.unmount()
render(
<AgentKanbanBoard
snapshot={{ generatedAt: 4, cards: [{ ...fresh, unseen: false }, old] }}
initialView="rings"
/>
)
expect(screen.queryByRole('button', { name: /Fresh result/ })).not.toBeInTheDocument()
})
})
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { XIcon } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Columns3, LayoutGrid, Orbit, XIcon } from 'lucide-react'
import {
DASHBOARD_BUCKET_ORDER,
type DashboardBucket,
@@ -12,7 +12,11 @@ import { TooltipProvider } from '@/components/ui/tooltip'
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
import { AgentKanbanCard } from './AgentKanbanCard'
import { AgentDashboardToolbar } from './AgentDashboardToolbar'
import { AgentTerminalDialog, type AgentRevealArgs } from './AgentTerminalDialog'
import {
AgentTerminalDialog,
AgentTerminalPanel,
type AgentRevealArgs
} from './AgentTerminalDialog'
import {
EMPTY_DASHBOARD_FILTERS,
filterDashboardCards,
@@ -20,6 +24,13 @@ import {
} from './agent-board-filtering'
import './agent-board-transitions.css'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import { AgentFleetRings } from './AgentFleetRings'
import { AgentCellMap } from './AgentCellMap'
import { DashboardOrchestratorChat } from './DashboardOrchestratorChat'
import { useFleetResultDisposition } from './use-fleet-result-disposition'
export type AgentDashboardView = 'rings' | 'board' | 'cells'
/** Ack an agent in the pop-out window: relayed over IPC to the main renderer.
* ?. shields dialog-opening from dev-HMR preload skew (renderer updates hot,
@@ -114,6 +125,7 @@ function KanbanColumn({
type AgentKanbanBoardProps = {
snapshot: DashboardSnapshot
initialView?: AgentDashboardView
/** Sizing for the outermost container. The pop-out fills the window
* (h-screen w-screen); the in-window drawer fills its host (h-full w-full). */
containerClassName?: string
@@ -136,12 +148,14 @@ type AgentKanbanBoardProps = {
* how ack/reveal are routed. */
export function AgentKanbanBoard({
snapshot,
initialView = 'board',
containerClassName = 'h-screen w-screen',
onAckAgent = ackAgentViaPopoutRelay,
onRevealAgent = revealAgentViaPopoutRelay,
onClose,
headerActions
}: AgentKanbanBoardProps): React.JSX.Element {
const [view, setView] = useState(initialView)
const visibleBuckets = useMemo(
() =>
DASHBOARD_BUCKET_ORDER.filter((bucket) => bucket !== 'idle' || snapshot.showIdle === true),
@@ -152,6 +166,7 @@ export function AgentKanbanBoard({
[snapshot.cards, visibleBuckets]
)
const [query, setQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
const [filters, setFilters] = useState<DashboardFilters>(EMPTY_DASHBOARD_FILTERS)
const filteredCards = useMemo(
() => filterDashboardCards(visibleCards, query, filters),
@@ -163,6 +178,9 @@ export function AgentKanbanBoard({
[snapshot.cards]
)
const [now, setNow] = useState(() => Date.now())
const [terminalPanelSide, setTerminalPanelSide] = useState<'left' | 'right'>('right')
const { reviewedPaneKeys, pinnedPaneKeys, acknowledge, markReviewed, togglePinned } =
useFleetResultDisposition(snapshot.cards, onAckAgent)
useEffect(() => {
if (!hasRelativeTimestamps) {
@@ -174,6 +192,27 @@ export function AgentKanbanBoard({
})
}, [hasRelativeTimestamps])
useEffect(() => {
const handleSearchShortcut = (event: KeyboardEvent): void => {
const usesPlatformModifier = navigator.userAgent.includes('Mac')
? event.metaKey
: event.ctrlKey
if (!usesPlatformModifier || event.key.toLowerCase() !== 'k') {
return
}
if (
event.target instanceof Element &&
event.target.closest('input, textarea, [contenteditable="true"], .xterm')
) {
return
}
event.preventDefault()
searchInputRef.current?.focus()
}
document.addEventListener('keydown', handleSearchShortcut)
return () => document.removeEventListener('keydown', handleSearchShortcut)
}, [])
// The open terminal dialog survives bucket moves: only the paneKey is
// remembered, and the card data is re-resolved from each fresh snapshot.
// The opened card is kept as a fallback so the dialog also survives the
@@ -203,25 +242,34 @@ export function AgentKanbanBoard({
// with unseen=false.
const handleOpenTerminal = useCallback(
(card: DashboardCard) => {
onAckAgent(card.paneKey)
acknowledge(card)
setOpenedCard(card)
},
[onAckAgent]
[acknowledge]
)
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(() => {
if (dialogCard?.unseen) {
onAckAgent(dialogCard.paneKey)
acknowledge(dialogCard)
}
}, [dialogCard?.unseen, dialogCard?.paneKey, onAckAgent])
}, [acknowledge, dialogCard])
return (
// Why: the pop-out is its own React root with no app-level provider, and the
// card's repo tooltip needs one in both hosts. Nesting inside the main
// window's provider is harmless.
<TooltipProvider delayDuration={300}>
<div className={cn('flex flex-col bg-background text-foreground', containerClassName)}>
<div
className={cn('relative flex flex-col bg-background text-foreground', containerClassName)}
>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<h1 className="text-[13px] font-semibold">
{translate('dashboardPopout.title', 'Agents')}
@@ -231,6 +279,45 @@ export function AgentKanbanBoard({
count: visibleCards.length
})}
</span>
<div
className="flex items-center gap-0.5 rounded-md border border-border p-0.5"
role="group"
aria-label={translate('dashboardPopout.view.label', 'Dashboard view')}
>
<Button
type="button"
variant="ghost"
size="xs"
aria-pressed={view === 'board'}
className={cn('h-6 gap-1 px-2', view === 'board' && 'bg-accent')}
onClick={() => setView('board')}
>
<Columns3 className="size-3" />
{translate('dashboardPopout.view.board', 'Dashboard')}
</Button>
<Button
type="button"
variant="ghost"
size="xs"
aria-pressed={view === 'rings'}
className={cn('h-6 gap-1 px-2', view === 'rings' && 'bg-accent')}
onClick={() => setView('rings')}
>
<Orbit className="size-3" />
{translate('dashboardPopout.view.rings', 'Agent Rings')}
</Button>
<Button
type="button"
variant="ghost"
size="xs"
aria-pressed={view === 'cells'}
className={cn('h-6 gap-1 px-2', view === 'cells' && 'bg-accent')}
onClick={() => setView('cells')}
>
<LayoutGrid className="size-3" />
{translate('dashboardPopout.view.cells', 'Cells')}
</Button>
</div>
{headerActions || onClose ? (
<div className="ml-auto flex items-center gap-1">
{headerActions}
@@ -255,27 +342,86 @@ export function AgentKanbanBoard({
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}
{view !== 'board' ? (
<div
className={cn(
'flex min-h-0 flex-1',
dialogCard && terminalPanelSide === 'left' && 'flex-row-reverse'
)}
>
{view === 'rings' ? (
<AgentFleetRings
cards={filteredCards}
now={now}
onOpenTerminal={handleOpenTerminal}
className={
dialogCard
? 'w-[clamp(14rem,28vw,22rem)] flex-none transition-[width] duration-200 motion-reduce:transition-none'
: undefined
}
compact={dialogCard !== null}
selectedPaneKey={dialogCard?.paneKey}
pinnedPaneKeys={pinnedPaneKeys}
reviewedPaneKeys={reviewedPaneKeys}
onMarkReviewed={markReviewed}
onOpenTerminal={handleOpenAdjacentTerminal}
/>
))}
) : (
<AgentCellMap
cards={filteredCards}
now={now}
className={
dialogCard
? 'w-[clamp(14rem,28vw,22rem)] flex-none transition-[width] duration-200 motion-reduce:transition-none'
: undefined
}
selectedPaneKey={dialogCard?.paneKey}
onOpenTerminal={handleOpenAdjacentTerminal}
/>
)}
{dialogCard ? (
<AgentTerminalPanel
card={dialogCard}
onOpenChange={handleDialogOpenChange}
onReveal={onRevealAgent}
reviewed={reviewedPaneKeys.has(dialogCard.paneKey)}
pinned={pinnedPaneKeys.has(dialogCard.paneKey)}
onMarkReviewed={(card) => markReviewed([card])}
onTogglePinned={togglePinned}
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>
</div>
<AgentTerminalDialog
card={dialogCard}
onOpenChange={handleDialogOpenChange}
onReveal={onRevealAgent}
/>
) : (
<div className="scrollbar-sleek flex min-h-0 flex-1 overflow-x-auto p-3">
{/* Auto margins center the capped board and collapse during horizontal overflow. */}
<div className="mx-auto flex w-full max-w-[1280px] gap-3">
{visibleBuckets.map((bucket) => (
<KanbanColumn
key={bucket}
bucket={bucket}
cards={grouped[bucket]}
repoIconsByRepoId={snapshot.repoIconsByRepoId}
now={now}
onOpenTerminal={handleOpenTerminal}
/>
))}
</div>
</div>
)}
{view === 'board' ? (
<AgentTerminalDialog
card={dialogCard}
onOpenChange={handleDialogOpenChange}
onReveal={onRevealAgent}
/>
) : null}
<DashboardOrchestratorChat cards={visibleCards} />
</div>
</TooltipProvider>
)
@@ -90,8 +90,11 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean {
a.worktreeId === b.worktreeId &&
a.tabId === b.tabId &&
a.leafId === b.leafId &&
a.parentPaneKey === b.parentPaneKey &&
a.repoName === b.repoName &&
a.worktreeName === b.worktreeName &&
a.hostKind === b.hostKind &&
a.workspaceKind === b.workspaceKind &&
a.hasReview === b.hasReview &&
a.review?.number === b.review?.number &&
a.review?.state === b.review?.state &&
@@ -2,27 +2,30 @@
import '@testing-library/jest-dom/vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type {
DashboardCard,
DashboardCardTerminalInput
} from '../../../../shared/dashboard-snapshot'
import { AgentTerminalDialog } from './AgentTerminalDialog'
import { AgentTerminalDialog, AgentTerminalPanel } from './AgentTerminalDialog'
// Stub the preview so the assertion is on the props the dialog hands it, with
// no xterm / IPC machinery in the way.
vi.mock('./AgentTerminalPreview', () => ({
AgentTerminalPreview: ({
ptyId,
terminalInput
terminalInput,
className
}: {
ptyId: string
terminalInput?: DashboardCardTerminalInput | null
className?: string
}) => (
<div
data-testid="preview"
data-pty-id={ptyId}
data-terminal-input={terminalInput === null ? 'null' : JSON.stringify(terminalInput)}
className={className}
/>
)
}))
@@ -85,4 +88,55 @@ describe('AgentTerminalDialog', () => {
expect(screen.getByTestId('preview')).toHaveAttribute('data-terminal-input', 'null')
})
it('offers ring result disposition actions without replacing the shared terminal dialog', () => {
const result = card({ bucket: 'done', dotState: 'done', finishedAt: 100 })
const onOpenChange = vi.fn()
const onMarkReviewed = vi.fn()
const onTogglePinned = vi.fn()
render(
<AgentTerminalDialog
card={result}
onOpenChange={onOpenChange}
onReveal={() => {}}
reviewed={false}
pinned={false}
onMarkReviewed={onMarkReviewed}
onTogglePinned={onTogglePinned}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Keep visible' }))
expect(onTogglePinned).toHaveBeenCalledWith(result)
fireEvent.click(screen.getByRole('button', { name: 'Mark reviewed' }))
expect(onMarkReviewed).toHaveBeenCalledWith(result)
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(screen.getByTestId('preview')).toHaveAttribute('data-pty-id', 'pty-1')
})
it('keeps a pinned reviewed result open', () => {
const onOpenChange = vi.fn()
render(
<AgentTerminalDialog
card={card({ bucket: 'done', dotState: 'done', finishedAt: 100 })}
onOpenChange={onOpenChange}
onReveal={() => {}}
reviewed={false}
pinned
onMarkReviewed={() => {}}
onTogglePinned={() => {}}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Mark reviewed' }))
expect(onOpenChange).not.toHaveBeenCalled()
})
it('reuses the terminal surface as a non-modal adjacent panel', () => {
render(<AgentTerminalPanel card={card()} onOpenChange={() => {}} onReveal={() => {}} />)
expect(screen.getByTestId('preview')).toHaveClass('min-h-0', 'flex-1')
expect(document.querySelector('[data-slot="dialog-overlay"]')).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'wt' })).toBeInTheDocument()
})
})
@@ -1,13 +1,14 @@
import { useCallback } from 'react'
import { SquareArrowOutUpRight, XIcon } from 'lucide-react'
import { useEffect, useId } from 'react'
import { Check, Pin, SquareArrowOutUpRight, XIcon } from 'lucide-react'
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
import { agentStateLabel } from '@/components/AgentStateDot'
import { Dialog, DialogClose, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { AgentTerminalPreview } from './AgentTerminalPreview'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
/** Routing payload for focusing an agent's pane in the main window. */
export type AgentRevealArgs = {
@@ -24,6 +25,122 @@ type AgentTerminalDialogProps = {
/** Focus the agent's pane. The pop-out relays over IPC; the in-window host
* activates the worktree/pane locally. */
onReveal: (args: AgentRevealArgs) => void
reviewed?: boolean
pinned?: boolean
onMarkReviewed?: (card: DashboardCard) => void
onTogglePinned?: (card: DashboardCard) => void
}
type AgentTerminalFrameProps = Omit<AgentTerminalDialogProps, 'card'> & {
card: DashboardCard
title: React.ReactNode
previewClassName?: string
}
function AgentTerminalFrame({
card,
title,
previewClassName,
onOpenChange,
onReveal,
reviewed = false,
pinned = false,
onMarkReviewed,
onTogglePinned
}: AgentTerminalFrameProps): React.JSX.Element {
const reveal = (): void => {
onReveal({
repoId: card.repoId,
worktreeId: card.worktreeId,
tabId: card.tabId,
leafId: card.leafId
})
onOpenChange(false)
}
return (
<>
<div className="flex shrink-0 items-center gap-1.5 px-2.5 py-2">
<span className="inline-flex shrink-0">
<AgentIcon agent={agentTypeToIconAgent(card.agentType)} size={13} />
</span>
{title}
<span className="text-[11px] text-muted-foreground">
{formatAgentTypeLabel(card.agentType)} · {agentStateLabel(card.dotState)}
</span>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="ml-auto opacity-70 hover:opacity-100"
onClick={() => onOpenChange(false)}
>
<XIcon className="size-4" />
<span className="sr-only">{translate('dashboardPopout.terminal.close', 'Close')}</span>
</Button>
</div>
{card.ptyId ? (
<AgentTerminalPreview
ptyId={card.ptyId}
terminalInput={card.terminalInput ?? null}
className={previewClassName}
/>
) : (
<div className="min-h-0 flex-1 px-2.5 pb-2 text-[11px] text-muted-foreground">
{translate(
'dashboardPopout.terminal.closed',
"No live terminal — this agent's pane has closed."
)}
</div>
)}
<div className="flex shrink-0 items-center gap-1.5 px-2.5 py-1.5">
{card.dotState === 'done' && onMarkReviewed && onTogglePinned ? (
<>
<span className="mr-1 text-[10px] text-muted-foreground">
{reviewed
? translate('dashboardPopout.rings.focus.reviewed', 'Reviewed result')
: translate(
'dashboardPopout.rings.focus.seenPendingReview',
'Seen · remains in Focus until reviewed'
)}
</span>
<Button
type="button"
variant="outline"
size="xs"
aria-pressed={pinned}
onClick={() => onTogglePinned(card)}
>
<Pin className="size-3" />
{pinned
? translate('dashboardPopout.rings.focus.pinned', 'Pinned')
: translate('dashboardPopout.rings.focus.keepVisible', 'Keep visible')}
</Button>
{!reviewed ? (
<Button
type="button"
variant="outline"
size="xs"
onClick={() => {
onMarkReviewed(card)
if (!pinned) {
onOpenChange(false)
}
}}
>
<Check className="size-3" />
{translate('dashboardPopout.rings.focus.markReviewed', 'Mark reviewed')}
</Button>
) : null}
</>
) : null}
<Button type="button" variant="outline" size="xs" className="ml-auto" onClick={reveal}>
<SquareArrowOutUpRight className="size-3" />
{translate('dashboardPopout.terminal.focusWorktree', 'Open worktree')}
</Button>
</div>
</>
)
}
/**
@@ -36,20 +153,12 @@ type AgentTerminalDialogProps = {
export function AgentTerminalDialog({
card,
onOpenChange,
onReveal
onReveal,
reviewed = false,
pinned = false,
onMarkReviewed,
onTogglePinned
}: AgentTerminalDialogProps): React.JSX.Element {
const reveal = useCallback(() => {
if (!card) {
return
}
onReveal({
repoId: card.repoId,
worktreeId: card.worktreeId,
tabId: card.tabId,
leafId: card.leafId
})
}, [card, onReveal])
return (
<Dialog open={card !== null} onOpenChange={onOpenChange}>
{card ? (
@@ -78,43 +187,77 @@ export function AgentTerminalDialog({
}
}}
>
<div className="flex items-center gap-1.5 px-2.5 py-2">
<span className="inline-flex shrink-0">
<AgentIcon agent={agentTypeToIconAgent(card.agentType)} size={13} />
</span>
<DialogTitle className="text-[12px] leading-normal font-semibold">
{card.worktreeName}
</DialogTitle>
<span className="text-[11px] text-muted-foreground">
{formatAgentTypeLabel(card.agentType)} · {agentStateLabel(card.dotState)}
</span>
<DialogClose className="ml-auto rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:outline-hidden">
<XIcon className="size-4" />
<span className="sr-only">
{translate('dashboardPopout.terminal.close', 'Close')}
</span>
</DialogClose>
</div>
{card.ptyId ? (
<AgentTerminalPreview ptyId={card.ptyId} terminalInput={card.terminalInput ?? null} />
) : (
<div className="px-2.5 pb-2 text-[11px] text-muted-foreground">
{translate(
'dashboardPopout.terminal.closed',
"No live terminal — this agent's pane has closed."
)}
</div>
)}
<div className="flex items-center justify-end px-2.5 py-1.5">
<DialogClose asChild>
<Button type="button" variant="outline" size="xs" onClick={reveal}>
<SquareArrowOutUpRight className="size-3" />
{translate('dashboardPopout.terminal.focusWorktree', 'Open worktree')}
</Button>
</DialogClose>
</div>
<AgentTerminalFrame
card={card}
title={
<DialogTitle className="text-[12px] leading-normal font-semibold">
{card.worktreeName}
</DialogTitle>
}
onOpenChange={onOpenChange}
onReveal={onReveal}
reviewed={reviewed}
pinned={pinned}
onMarkReviewed={onMarkReviewed}
onTogglePinned={onTogglePinned}
/>
</DialogContent>
) : null}
</Dialog>
)
}
export function AgentTerminalPanel({
card,
onOpenChange,
onReveal,
reviewed = false,
pinned = false,
onMarkReviewed,
onTogglePinned,
className
}: Omit<AgentTerminalDialogProps, 'card'> & {
card: DashboardCard
className?: string
}): React.JSX.Element {
const titleId = useId()
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
if (
event.key === 'Escape' &&
!(event.target instanceof HTMLElement && event.target.closest('.xterm'))
) {
onOpenChange(false)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onOpenChange])
return (
<section
aria-labelledby={titleId}
className={cn(
'm-3 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-[0_10px_24px_rgba(0,0,0,0.18)]',
className
)}
>
<AgentTerminalFrame
card={card}
title={
<h2 id={titleId} className="text-[12px] leading-normal font-semibold">
{card.worktreeName}
</h2>
}
previewClassName="h-auto min-h-0 flex-1"
onOpenChange={onOpenChange}
onReveal={onReveal}
reviewed={reviewed}
pinned={pinned}
onMarkReviewed={onMarkReviewed}
onTogglePinned={onTogglePinned}
/>
</section>
)
}
@@ -49,11 +49,13 @@ function clamp(value: number, min: number, max: number): number {
*/
export function AgentTerminalPreview({
ptyId,
terminalInput = null
terminalInput = null,
className
}: {
ptyId: string
/** Host-input facts relayed with the card; null routes bytes by client OS. */
terminalInput?: DashboardCardTerminalInput | null
className?: string
}): React.JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
@@ -444,7 +446,10 @@ export function AgentTerminalPreview({
// buffer is. The terminal keeps the pane's true dimensions and is scaled/
// clipped to fit; fitToBox anchors whichever end keeps the cursor in view.
<div
className="relative h-[calc(100vh-140px)] w-full overflow-hidden bg-background p-1.5"
className={cn(
'relative h-[calc(100vh-140px)] w-full overflow-hidden bg-background p-1.5',
className
)}
style={terminalTheme?.background ? { backgroundColor: terminalTheme.background } : undefined}
>
{ptyGone ? (
@@ -0,0 +1,210 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { buildNativeChatPasteBytes } from '@/components/native-chat/native-chat-send'
import { DashboardOrchestratorChat } from './DashboardOrchestratorChat'
import { DASHBOARD_ORCHESTRATOR_CONTEXT_MIME } from './dashboard-orchestrator-context'
import * as dashboardOrchestratorContext from './dashboard-orchestrator-context'
vi.mock('@/lib/agent-catalog', () => ({
AgentIcon: ({ agent }: { agent: string }) => <span data-agent-icon={agent} />
}))
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
return {
paneKey: 'pane-1',
ptyId: 'pty-1',
agentType: 'codex',
bucket: 'working',
dotState: 'working',
task: 'Coordinate the fleet',
repoId: 'repo-1',
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
repoName: 'Orca',
worktreeName: 'Fleet',
conversationName: 'Coordinator',
startedAt: 1,
finishedAt: null,
stateChangedAt: 10,
unseen: false,
...overrides
}
}
function dataTransfer(): DataTransfer {
const values = new Map<string, string>()
return {
dropEffect: 'none',
effectAllowed: 'none',
files: [] as unknown as FileList,
items: [] as unknown as DataTransferItemList,
types: [DASHBOARD_ORCHESTRATOR_CONTEXT_MIME],
clearData: vi.fn(),
getData: (type: string) => values.get(type) ?? '',
setData: (type: string, value: string) => {
values.set(type, value)
},
setDragImage: vi.fn()
} as DataTransfer
}
describe('DashboardOrchestratorChat', () => {
const input = vi.fn(async () => true)
beforeEach(() => {
Object.assign(window, { api: { terminalPreview: { input } } })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('keeps the transport route hidden while dispatching through the strongest coordinator', async () => {
const coordinator = card({
paneKey: 'coordinator',
ptyId: 'pty-coordinator',
subagents: [{ id: 'child', name: 'Reviewer', dotState: 'working' }]
})
render(
<DashboardOrchestratorChat
cards={[card({ paneKey: 'worker', ptyId: 'pty-worker' }), coordinator]}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Orchestrate' }))
expect(await screen.findByText('Global fleet · 2 agents')).toBeInTheDocument()
expect(screen.getByText('$orchestration')).toBeInTheDocument()
expect(screen.queryByText(/Routes through/)).not.toBeInTheDocument()
const composer = screen.getByRole('textbox', { name: 'Message the orchestrator' })
fireEvent.change(composer, { target: { value: 'Summarize the risky work.' } })
fireEvent.keyDown(composer, { key: 'Enter' })
await waitFor(() => expect(input).toHaveBeenCalledTimes(2))
expect(input).toHaveBeenNthCalledWith(
1,
'pty-coordinator',
buildNativeChatPasteBytes(
'$orchestration Coordinate this request across the entire Orca fleet.\n\nSummarize the risky work.'
)
)
expect(input).toHaveBeenNthCalledWith(2, 'pty-coordinator', '\r')
expect(await screen.findByText('Summarize the risky work.')).toBeInTheDocument()
expect(composer).toHaveValue('')
})
it('builds the fleet hierarchy only while the orchestrator is open', async () => {
const buildProjects = vi.spyOn(
dashboardOrchestratorContext,
'buildDashboardOrchestratorProjects'
)
const { rerender } = render(<DashboardOrchestratorChat cards={[card()]} />)
expect(buildProjects).not.toHaveBeenCalled()
rerender(<DashboardOrchestratorChat cards={[card({ stateChangedAt: 20 })]} />)
expect(buildProjects).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Orchestrate' }))
await waitFor(() => expect(buildProjects).toHaveBeenCalledOnce())
buildProjects.mockRestore()
})
it('adds a dragged agent to the prompt context', async () => {
render(<DashboardOrchestratorChat cards={[card()]} />)
fireEvent.click(screen.getByRole('button', { name: 'Orchestrate' }))
const transfer = dataTransfer()
fireEvent.dragStart(
await screen.findByRole('button', {
name: 'Add Coordinator to orchestration context'
}),
{ dataTransfer: transfer }
)
const dropZone = document.querySelector('[data-orchestrator-drop-zone]')
expect(dropZone).not.toBeNull()
fireEvent.dragOver(dropZone as Element, { dataTransfer: transfer })
fireEvent.drop(dropZone as Element, { dataTransfer: transfer })
const composer = screen.getByRole('textbox', { name: 'Message the orchestrator' })
fireEvent.change(composer, { target: { value: 'Check this agent.' } })
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() => expect(input).toHaveBeenCalledTimes(2))
expect(input).toHaveBeenNthCalledWith(
1,
'pty-1',
buildNativeChatPasteBytes(
'$orchestration Coordinate this request with these selected fleet participants: agent "Coordinator" in workspace "Fleet".\n\nCheck this agent.'
)
)
})
it('hides the introduction manually and after the first message', async () => {
const first = render(<DashboardOrchestratorChat cards={[card()]} />)
fireEvent.click(screen.getByRole('button', { name: 'Orchestrate' }))
expect(await screen.findByText('One conversation, fleet-wide context')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Hide introduction' }))
expect(screen.queryByText('One conversation, fleet-wide context')).not.toBeInTheDocument()
first.unmount()
render(<DashboardOrchestratorChat cards={[card()]} />)
fireEvent.click(screen.getByRole('button', { name: 'Orchestrate' }))
const composer = await screen.findByRole('textbox', { name: 'Message the orchestrator' })
fireEvent.change(composer, { target: { value: 'Start coordinating.' } })
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() => expect(input).toHaveBeenCalledTimes(2))
expect(screen.queryByText('One conversation, fleet-wide context')).not.toBeInTheDocument()
})
it('does not surface the backing agent conversation before a new orchestration reply', async () => {
const { rerender } = render(
<DashboardOrchestratorChat cards={[card({ lastAgentMessage: 'Unrelated PR discussion' })]} />
)
fireEvent.click(screen.getByRole('button', { name: 'Orchestrate' }))
expect(screen.queryByText('Unrelated PR discussion')).not.toBeInTheDocument()
const composer = screen.getByRole('textbox', { name: 'Message the orchestrator' })
fireEvent.change(composer, { target: { value: 'Inspect the fleet.' } })
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() => expect(input).toHaveBeenCalledTimes(2))
rerender(
<DashboardOrchestratorChat
cards={[card({ lastAgentMessage: 'Three agents need your attention.' })]}
/>
)
expect(await screen.findByText('Three agents need your attention.')).toBeInTheDocument()
})
it('keeps the draft when the live terminal rejects the request', async () => {
input.mockResolvedValueOnce(false)
render(<DashboardOrchestratorChat cards={[card()]} />)
fireEvent.click(screen.getByRole('button', { name: 'Orchestrate' }))
const composer = await screen.findByRole('textbox', { name: 'Message the orchestrator' })
fireEvent.change(composer, { target: { value: 'Try this again.' } })
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
expect(await screen.findByText('Send failed')).toBeInTheDocument()
expect(composer).toHaveValue('Try this again.')
})
it('reports the absence of a live agent without overclaiming availability', async () => {
const { container } = render(
<DashboardOrchestratorChat
cards={[card({ ptyId: null, bucket: 'done', dotState: 'done' })]}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Orchestrate' }))
expect(await screen.findByText('No live agent')).toBeInTheDocument()
expect(screen.getByText('Start a live agent to enable orchestration')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled()
expect(container.querySelector('.bg-status-success')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,262 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { BotMessageSquare, Sparkles } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
buildNativeChatPasteBytes,
NATIVE_CHAT_SUBMIT
} from '@/components/native-chat/native-chat-send'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { DashboardOrchestratorConversation } from './DashboardOrchestratorConversation'
import { DashboardOrchestratorFleetRail } from './DashboardOrchestratorFleetRail'
import {
buildDashboardOrchestrationPrompt,
buildDashboardOrchestratorProjects,
listDashboardOrchestratorContexts,
type DashboardOrchestratorContext,
type DashboardOrchestratorProject
} from './dashboard-orchestrator-context'
import {
appendDashboardOrchestratorMessage,
type DashboardOrchestratorMessage
} from './dashboard-orchestrator-message-history'
type PendingReply = {
paneKey: string
previousMessage: string | null
}
const ORCHESTRATION_COMMAND = '$orchestration'
const EMPTY_PROJECTS: DashboardOrchestratorProject[] = []
const EMPTY_CONTEXTS_BY_ID = new Map<string, DashboardOrchestratorContext>()
function candidateRank(card: DashboardCard): number {
const coordinatorEvidence = (card.subagents?.length ?? 0) > 0 ? 0 : 100
const stateRank = card.bucket === 'attention' ? 0 : card.bucket === 'working' ? 10 : 20
return coordinatorEvidence + stateRank
}
function bestFleetCandidate(cards: DashboardCard[]): DashboardCard | null {
let best: DashboardCard | null = null
let bestRank = Number.POSITIVE_INFINITY
for (const card of cards) {
if (card.ptyId === null) {
continue
}
const rank = candidateRank(card)
if (
!best ||
rank < bestRank ||
(rank === bestRank && card.stateChangedAt > best.stateChangedAt)
) {
best = card
bestRank = rank
}
}
return best
}
export function DashboardOrchestratorChat({
cards
}: {
cards: DashboardCard[]
}): React.JSX.Element {
const composerRef = useRef<HTMLTextAreaElement>(null)
const nextMessageId = useRef(0)
const [open, setOpen] = useState(false)
const [draft, setDraft] = useState('')
const [sending, setSending] = useState(false)
const [sendError, setSendError] = useState(false)
const [introVisible, setIntroVisible] = useState(true)
const [contexts, setContexts] = useState<DashboardOrchestratorContext[]>([])
const [messages, setMessages] = useState<DashboardOrchestratorMessage[]>([])
const [pendingReply, setPendingReply] = useState<PendingReply | null>(null)
const target = useMemo(() => bestFleetCandidate(cards), [cards])
const projects = useMemo(
() => (open ? buildDashboardOrchestratorProjects(cards) : EMPTY_PROJECTS),
[cards, open]
)
const contextsById = useMemo(
() =>
open
? new Map(
listDashboardOrchestratorContexts(projects).map((context) => [context.id, context])
)
: EMPTY_CONTEXTS_BY_ID,
[open, projects]
)
const selectedContextIds = useMemo(
() => new Set(contexts.map((context) => context.id)),
[contexts]
)
useEffect(() => {
if (!pendingReply) {
return
}
const reply = cards
.find((card) => card.paneKey === pendingReply.paneKey)
?.lastAgentMessage?.trim()
if (!reply || reply === pendingReply.previousMessage) {
return
}
nextMessageId.current += 1
setMessages((current) =>
appendDashboardOrchestratorMessage(current, {
id: nextMessageId.current,
role: 'assistant',
text: reply
})
)
setPendingReply(null)
}, [cards, pendingReply])
const toggleContext = (context: DashboardOrchestratorContext): void => {
setContexts((current) =>
current.some((entry) => entry.id === context.id)
? current.filter((entry) => entry.id !== context.id)
: [...current, context]
)
}
const addContextId = (contextId: string): void => {
const context = contextsById.get(contextId)
if (!context) {
return
}
setContexts((current) =>
current.some((entry) => entry.id === context.id) ? current : [...current, context]
)
}
const send = async (): Promise<void> => {
const message = draft.trim()
if (!message || !target?.ptyId || sending) {
return
}
setSending(true)
setSendError(false)
try {
const bodySent = await window.api.terminalPreview.input(
target.ptyId,
buildNativeChatPasteBytes(buildDashboardOrchestrationPrompt(message, contexts))
)
const submitted =
bodySent && (await window.api.terminalPreview.input(target.ptyId, NATIVE_CHAT_SUBMIT))
if (!submitted) {
setSendError(true)
return
}
nextMessageId.current += 1
setMessages((current) =>
appendDashboardOrchestratorMessage(current, {
id: nextMessageId.current,
role: 'user',
text: message
})
)
setPendingReply({
paneKey: target.paneKey,
previousMessage: target.lastAgentMessage?.trim() ?? null
})
setIntroVisible(false)
setDraft('')
} catch {
setSendError(true)
} finally {
setSending(false)
}
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
className="absolute right-4 bottom-4 z-30 h-10 gap-2 rounded-full px-4 shadow-[0_10px_24px_rgba(0,0,0,0.18)]"
>
<span className="relative">
<BotMessageSquare className="size-4" />
<span
className={cn(
'absolute -top-0.5 -right-0.5 size-1.5 rounded-full ring-2 ring-primary',
target ? 'bg-status-success' : 'bg-muted-foreground/45'
)}
/>
</span>
{translate('dashboardPopout.orchestrator.trigger', 'Orchestrate')}
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
align="end"
sideOffset={10}
onOpenAutoFocus={(event) => {
event.preventDefault()
composerRef.current?.focus()
}}
className="flex h-[min(680px,calc(100vh-96px))] w-[min(780px,calc(100vw-32px))] flex-col overflow-hidden p-0"
>
<header className="flex shrink-0 items-center gap-3 border-b border-border px-4 py-3">
<span className="grid size-9 place-items-center rounded-lg border border-border bg-muted">
<Sparkles className="size-4" />
</span>
<span className="min-w-0">
<strong className="block text-sm">
{translate('dashboardPopout.orchestrator.title', 'Orchestrator')}
</strong>
<span className="block text-[11px] text-muted-foreground">
{translate('dashboardPopout.orchestrator.scope', 'Global fleet · {{count}} agents', {
count: cards.length
})}
</span>
</span>
<span className="ml-auto flex items-center gap-1.5 text-[10px] text-muted-foreground">
<i
className={cn(
'size-1.5 rounded-full',
target ? 'bg-status-success' : 'bg-muted-foreground/45'
)}
/>
{target
? translate('dashboardPopout.orchestrator.ready', 'Ready')
: translate('dashboardPopout.orchestrator.noLiveAgentStatus', 'No live agent')}
</span>
</header>
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-muted/30 px-4 py-2">
<code className="rounded-md border border-border bg-background px-2 py-1 text-[11px] font-semibold">
{ORCHESTRATION_COMMAND}
</code>
<span className="text-[10px] text-muted-foreground">
{translate('dashboardPopout.orchestrator.commandActive', 'Global fleet command active')}
</span>
</div>
<div className="grid min-h-0 flex-1 grid-cols-[250px_minmax(0,1fr)]">
<DashboardOrchestratorFleetRail
projects={projects}
selectedContextIds={selectedContextIds}
onToggleContext={toggleContext}
/>
<DashboardOrchestratorConversation
composerRef={composerRef}
contexts={contexts}
messages={messages}
draft={draft}
introVisible={introVisible}
pendingReply={pendingReply !== null}
sending={sending}
sendError={sendError}
targetAvailable={target !== null}
onAddContextId={addContextId}
onToggleContext={toggleContext}
onDraftChange={setDraft}
onHideIntro={() => setIntroVisible(false)}
onSend={() => void send()}
/>
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,265 @@
import { useState } from 'react'
import { Send, Sparkles, X } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
DASHBOARD_ORCHESTRATOR_CONTEXT_MIME,
type DashboardOrchestratorContext
} from './dashboard-orchestrator-context'
import type { DashboardOrchestratorMessage } from './dashboard-orchestrator-message-history'
export type { DashboardOrchestratorMessage } from './dashboard-orchestrator-message-history'
type DashboardOrchestratorConversationProps = {
composerRef: React.RefObject<HTMLTextAreaElement | null>
contexts: DashboardOrchestratorContext[]
messages: DashboardOrchestratorMessage[]
draft: string
introVisible: boolean
pendingReply: boolean
sending: boolean
sendError: boolean
targetAvailable: boolean
onAddContextId: (contextId: string) => void
onToggleContext: (context: DashboardOrchestratorContext) => void
onDraftChange: (draft: string) => void
onHideIntro: () => void
onSend: () => void
}
const SUGGESTIONS = [
['attention', 'What needs my attention across the fleet?'],
['summary', 'Summarize active work across every project.'],
['stalled', 'Find agents that may be stalled or running unusually long.']
] as const
export function DashboardOrchestratorConversation({
composerRef,
contexts,
messages,
draft,
introVisible,
pendingReply,
sending,
sendError,
targetAvailable,
onAddContextId,
onToggleContext,
onDraftChange,
onHideIntro,
onSend
}: DashboardOrchestratorConversationProps): React.JSX.Element {
const [dropActive, setDropActive] = useState(false)
return (
<section className="flex min-h-0 min-w-0 flex-col">
<div
className={cn(
'flex min-h-0 flex-1 flex-col transition-colors',
dropActive && 'bg-accent/45 ring-2 ring-inset ring-ring'
)}
data-orchestrator-drop-zone=""
onDragOver={(event) => {
if (event.dataTransfer.types.includes(DASHBOARD_ORCHESTRATOR_CONTEXT_MIME)) {
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
setDropActive(true)
}
}}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setDropActive(false)
}
}}
onDrop={(event) => {
event.preventDefault()
setDropActive(false)
onAddContextId(event.dataTransfer.getData(DASHBOARD_ORCHESTRATOR_CONTEXT_MIME))
}}
>
<div className="shrink-0 border-b border-border px-4 py-2.5">
<div className="flex min-h-7 flex-wrap items-center gap-1.5">
{contexts.length === 0 ? (
<>
<span className="rounded-full border border-border bg-muted px-2 py-1 text-[10px] font-medium">
{translate('dashboardPopout.orchestrator.entireFleet', 'Entire fleet')}
</span>
<span className="text-[10px] text-muted-foreground">
{translate(
'dashboardPopout.orchestrator.dropContext',
'Drop a worktree or agent here to narrow context'
)}
</span>
</>
) : (
contexts.map((context) => (
<span
key={context.id}
className="inline-flex max-w-48 items-center gap-1 rounded-full border border-border bg-muted px-2 py-1 text-[10px]"
>
<span className="truncate">{context.label}</span>
<button
type="button"
onClick={() => onToggleContext(context)}
aria-label={translate(
'dashboardPopout.orchestrator.removeContext',
'Remove {{context}} from orchestration context',
{ context: context.label }
)}
className="rounded-full text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-2.5" />
</button>
</span>
))
)}
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-4 p-4">
{introVisible ? (
<div className="relative rounded-lg border border-border bg-muted/45 p-3 pr-9">
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute top-2 right-2"
onClick={onHideIntro}
aria-label={translate(
'dashboardPopout.orchestrator.hideIntro',
'Hide introduction'
)}
>
<X className="size-3" />
</Button>
<span className="flex items-center gap-2 text-xs font-semibold">
<Sparkles className="size-3.5" />
{translate(
'dashboardPopout.orchestrator.globalContext',
'One conversation, fleet-wide context'
)}
</span>
<p className="mt-1.5 text-[11px] leading-relaxed text-muted-foreground">
{translate(
'dashboardPopout.orchestrator.globalContextCopy',
'Requests use Orca orchestration to inspect and coordinate agents across projects, workspaces, and hosts.'
)}
</p>
</div>
) : null}
{messages.map((message) =>
message.role === 'user' ? (
<div
key={message.id}
className="ml-12 rounded-lg bg-primary px-3 py-2.5 text-xs text-primary-foreground"
>
{message.text}
</div>
) : (
<div
key={message.id}
className="mr-12 rounded-lg border border-border bg-card px-3 py-2.5 text-xs leading-relaxed"
>
{message.text}
</div>
)
)}
{pendingReply ? (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="size-1.5 animate-pulse rounded-full bg-foreground" />
{translate('dashboardPopout.orchestrator.coordinating', 'Coordinating…')}
</div>
) : null}
{messages.length === 0 ? (
<div>
<span className="text-[10px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate('dashboardPopout.orchestrator.try', 'Try')}
</span>
<div className="mt-2 flex flex-wrap gap-1.5">
{SUGGESTIONS.map(([id, fallback]) => (
<Button
key={id}
type="button"
variant="outline"
size="xs"
className="h-auto min-h-6 whitespace-normal py-1 text-left text-[10px]"
onClick={() =>
onDraftChange(
translate(`dashboardPopout.orchestrator.suggestions.${id}`, fallback)
)
}
>
{translate(`dashboardPopout.orchestrator.suggestions.${id}`, fallback)}
</Button>
))}
</div>
</div>
) : null}
</div>
</ScrollArea>
</div>
<footer className="shrink-0 bg-background px-4 pt-2 pb-4">
<div className="rounded-lg border border-border bg-muted/50 p-1.5 shadow-xs dark:bg-input/40">
<textarea
ref={composerRef}
value={draft}
rows={2}
onChange={(event) => onDraftChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault()
onSend()
}
}}
placeholder={translate('dashboardPopout.orchestrator.placeholder', 'Ask the fleet…')}
aria-label={translate(
'dashboardPopout.orchestrator.composerLabel',
'Message the orchestrator'
)}
className="scrollbar-sleek min-h-12 max-h-[calc(8lh+0.5rem)] w-full resize-none bg-transparent px-2 py-1 text-sm outline-none [field-sizing:content] placeholder:text-muted-foreground/60"
/>
<div className="flex items-center gap-2 pt-0.5">
<span
className={cn(
'text-[10px]',
sendError ? 'text-destructive' : 'text-muted-foreground'
)}
>
{sendError
? translate('dashboardPopout.orchestrator.sendFailed', 'Send failed')
: targetAvailable
? translate(
'dashboardPopout.orchestrator.globalOrchestration',
'Global orchestration'
)
: translate(
'dashboardPopout.orchestrator.startLiveAgentHint',
'Start a live agent to enable orchestration'
)}
</span>
<Button
type="button"
size="xs"
className="ml-auto"
disabled={!targetAvailable || !draft.trim() || sending}
onClick={onSend}
aria-label={translate('dashboardPopout.orchestrator.send', 'Send')}
>
<Send className="size-3" />
{sending
? translate('dashboardPopout.orchestrator.sending', 'Sending…')
: translate('dashboardPopout.orchestrator.send', 'Send')}
</Button>
</div>
</div>
</footer>
</section>
)
}
@@ -0,0 +1,199 @@
import { Search } from 'lucide-react'
import { useState } from 'react'
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import type { DashboardCardDotState } from '../../../../shared/dashboard-snapshot'
import {
dashboardAgentContext,
DASHBOARD_ORCHESTRATOR_CONTEXT_MIME,
type DashboardOrchestratorContext,
type DashboardOrchestratorProject
} from './dashboard-orchestrator-context'
type DashboardOrchestratorFleetRailProps = {
projects: DashboardOrchestratorProject[]
selectedContextIds: Set<string>
onToggleContext: (context: DashboardOrchestratorContext) => void
}
function statusDotClass(status: DashboardCardDotState): string {
if (status === 'blocked') {
return 'bg-destructive'
}
if (status === 'waiting') {
return 'bg-amber-500'
}
if (status === 'done') {
return 'bg-status-success'
}
if (status === 'idle') {
return 'bg-muted-foreground/45'
}
return 'border border-foreground bg-background'
}
function startContextDrag(event: React.DragEvent, context: DashboardOrchestratorContext): void {
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData(DASHBOARD_ORCHESTRATOR_CONTEXT_MIME, context.id)
}
function contextButtonClass(selected: boolean): string {
return cn(
'border border-border bg-background shadow-xs transition-colors hover:bg-accent',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
selected && 'border-ring bg-accent ring-1 ring-ring/45'
)
}
function matchesFleetQuery(project: DashboardOrchestratorProject, query: string): boolean {
if (!query) {
return true
}
return (
project.name.toLowerCase().includes(query) ||
project.workspaces.some(
(workspace) =>
workspace.name.toLowerCase().includes(query) ||
workspace.cards.some((card) =>
(card.conversationName ?? formatAgentTypeLabel(card.agentType))
.toLowerCase()
.includes(query)
)
)
)
}
export function DashboardOrchestratorFleetRail({
projects,
selectedContextIds,
onToggleContext
}: DashboardOrchestratorFleetRailProps): React.JSX.Element {
const [query, setQuery] = useState('')
const normalizedQuery = query.trim().toLowerCase()
const visibleProjects = projects.filter((project) => matchesFleetQuery(project, normalizedQuery))
return (
<aside className="flex min-h-0 flex-col border-r border-border bg-muted/20">
<div className="shrink-0 border-b border-border p-3">
<div className="flex items-baseline justify-between gap-2">
<strong className="text-xs">
{translate('dashboardPopout.orchestrator.fleetContext', 'Fleet context')}
</strong>
<span className="text-[10px] text-muted-foreground">
{translate('dashboardPopout.orchestrator.dragHint', 'Drag into chat')}
</span>
</div>
<div className="relative mt-2">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label={translate(
'dashboardPopout.orchestrator.filterFleet',
'Filter fleet context'
)}
placeholder={translate('dashboardPopout.orchestrator.filterPlaceholder', 'Find…')}
className="h-7 pl-7 text-xs"
/>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-3 p-3">
{visibleProjects.map((project) => (
<section key={project.id} className="space-y-1.5">
<button
type="button"
draggable
aria-pressed={selectedContextIds.has(project.context.id)}
className={cn(
contextButtonClass(selectedContextIds.has(project.context.id)),
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left'
)}
onDragStart={(event) => startContextDrag(event, project.context)}
onClick={() => onToggleContext(project.context)}
>
<span className="size-2.5 rounded-full border border-ring/60 bg-card ring-2 ring-muted" />
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold">
{project.name}
</span>
<span className="text-[10px] text-muted-foreground">{project.agentCount}</span>
</button>
<div className="ml-2 space-y-1.5 border-l border-border pl-2">
{project.workspaces.map((workspace) => (
<div
key={workspace.id}
className="rounded-lg border border-border bg-card/65 p-2"
>
<button
type="button"
draggable
aria-pressed={selectedContextIds.has(workspace.context.id)}
className={cn(
contextButtonClass(selectedContextIds.has(workspace.context.id)),
'flex w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-left'
)}
onDragStart={(event) => startContextDrag(event, workspace.context)}
onClick={() => onToggleContext(workspace.context)}
>
<span className="size-1.5 rounded-full bg-muted-foreground/55" />
<span className="min-w-0 flex-1 truncate text-[10px] font-medium">
{workspace.name}
</span>
<span className="text-[9px] text-muted-foreground">
{workspace.cards.length}
</span>
</button>
<div className="mt-2 flex flex-wrap gap-1.5">
{workspace.cards.map((card) => {
const context = dashboardAgentContext(card, project.name, workspace.name)
const selected = selectedContextIds.has(context.id)
const name = card.conversationName ?? formatAgentTypeLabel(card.agentType)
return (
<button
key={card.paneKey}
type="button"
draggable
aria-label={translate(
'dashboardPopout.orchestrator.addAgentContext',
'Add {{agent}} to orchestration context',
{ agent: name }
)}
aria-pressed={selected}
title={name}
className={cn(
contextButtonClass(selected),
'relative grid size-7 place-items-center rounded-full'
)}
onDragStart={(event) => startContextDrag(event, context)}
onClick={() => onToggleContext(context)}
>
<AgentIcon agent={agentTypeToIconAgent(card.agentType)} size={12} />
<span
className={cn(
'absolute -right-0.5 -bottom-0.5 size-2 rounded-full ring-2 ring-card',
statusDotClass(card.dotState)
)}
/>
</button>
)
})}
</div>
</div>
))}
</div>
</section>
))}
{visibleProjects.length === 0 ? (
<p className="py-6 text-center text-[11px] text-muted-foreground">
{translate('dashboardPopout.orchestrator.noFleetMatches', 'No matches')}
</p>
) : null}
</div>
</ScrollArea>
</aside>
)
}
@@ -1,9 +1,8 @@
import { AgentKanbanBoard } from './AgentKanbanBoard'
import { AgentKanbanBoard, type AgentDashboardView } from './AgentKanbanBoard'
import { useDashboardSnapshot } from './useDashboardSnapshot'
type DashboardPopoutRootProps = {
/** The layout requested via popout.html?view=<name>. Only "kanban" exists
* today; unknown views fall back to it. */
/** The layout requested via popout.html?view=<name>. */
view: string | null
}
@@ -13,5 +12,7 @@ type DashboardPopoutRootProps = {
*/
export function DashboardPopoutRoot(_props: DashboardPopoutRootProps): React.JSX.Element {
const snapshot = useDashboardSnapshot()
return <AgentKanbanBoard snapshot={snapshot} />
const initialView: AgentDashboardView =
_props.view === 'rings' || _props.view === 'cells' ? _props.view : 'board'
return <AgentKanbanBoard snapshot={snapshot} initialView={initialView} />
}
@@ -0,0 +1,205 @@
import { useState } from 'react'
import { Send, SquareArrowOutUpRight } from 'lucide-react'
import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { Button } from '@/components/ui/button'
import { formatAgentTypeLabel } from '@/lib/agent-status'
import { translate } from '@/i18n/i18n'
import {
buildNativeChatPasteBytes,
NATIVE_CHAT_SUBMIT
} from '@/components/native-chat/native-chat-send'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import type { FleetWorktreeRing } from './fleet-rings-layout'
type FleetAgentInspectorProps = {
card: DashboardCard | null
worktree: FleetWorktreeRing | null
draft: string
onDraftChange: (value: string) => void
onOpenTerminal: (card: DashboardCard) => void
}
function compactDuration(card: DashboardCard, now: number): string {
if (card.startedAt <= 0) {
return translate('dashboardPopout.rings.durationUnknown', 'Duration unavailable')
}
const end = card.finishedAt ?? now
const minutes = Math.max(0, Math.floor((end - card.startedAt) / 60_000))
if (minutes < 60) {
return translate('dashboardPopout.rings.durationMinutes', '{{count}}m active', {
count: minutes
})
}
return translate('dashboardPopout.rings.durationHours', '{{count}}h active', {
count: Math.floor(minutes / 60)
})
}
export function FleetAgentInspector({
card,
worktree,
draft,
onDraftChange,
onOpenTerminal
}: FleetAgentInspectorProps): React.JSX.Element {
const [sending, setSending] = useState(false)
const [sendError, setSendError] = useState(false)
const isMac = navigator.userAgent.includes('Mac')
if (!card || !worktree) {
return (
<aside className="hidden min-h-0 w-64 shrink-0 border-l border-border bg-card/35 p-4 md:block">
<span className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate('dashboardPopout.rings.selectedAgent', 'Selected agent')}
</span>
<p className="mt-2 text-xs text-muted-foreground">
{translate(
'dashboardPopout.rings.selectAgentHint',
'Select an agent node to pin its details and response draft.'
)}
</p>
</aside>
)
}
const sendDraft = async (): Promise<void> => {
const message = draft.trim()
if (!card.ptyId || !message || sending) {
return
}
setSending(true)
setSendError(false)
try {
const bodySent = await window.api.terminalPreview.input(
card.ptyId,
buildNativeChatPasteBytes(message)
)
const submitted =
bodySent && (await window.api.terminalPreview.input(card.ptyId, NATIVE_CHAT_SUBMIT))
if (submitted) {
onDraftChange('')
} else {
setSendError(true)
}
} catch {
setSendError(true)
} finally {
setSending(false)
}
}
const statusCounts = worktree.statusCounts
return (
<aside className="scrollbar-sleek hidden min-h-0 w-64 shrink-0 overflow-y-auto border-l border-border bg-card/35 p-4 md:flex md:flex-col">
<header>
<span className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate('dashboardPopout.rings.selectedAgent', 'Selected agent')}
</span>
<h2 className="mt-1 truncate text-sm font-semibold">
{card.conversationName ?? formatAgentTypeLabel(card.agentType)}
</h2>
<p className="truncate text-[11px] text-muted-foreground">{card.worktreeName}</p>
</header>
<div className="mt-3 grid grid-cols-3 gap-1.5">
<span className="rounded-md bg-muted p-2 text-[10px] text-muted-foreground">
<strong className="block text-sm text-foreground">
{statusCounts.blocked + statusCounts.waiting}
</strong>
{translate('dashboardPopout.bucket.attention', 'Needs You')}
</span>
<span className="rounded-md bg-muted p-2 text-[10px] text-muted-foreground">
<strong className="block text-sm text-foreground">{statusCounts.working}</strong>
{translate('dashboardPopout.bucket.working', 'Working')}
</span>
<span className="rounded-md bg-muted p-2 text-[10px] text-muted-foreground">
<strong className="block text-sm text-foreground">{worktree.agents.length}</strong>
{translate('dashboardPopout.rings.inRing', 'In ring')}
</span>
</div>
<section className="mt-4 border-t border-border pt-4">
<div className="flex items-center gap-2">
<AgentStateDot state={card.dotState} size="md" />
<span className="min-w-0">
<strong className="block text-xs">{agentStateLabel(card.dotState)}</strong>
<span className="block text-[11px] text-muted-foreground">
{compactDuration(card, Date.now())}
</span>
</span>
</div>
{card.task || card.lastAgentMessage ? (
<p className="mt-3 line-clamp-5 text-xs leading-relaxed text-muted-foreground">
{card.lastAgentMessage || card.task}
</p>
) : null}
<Button
type="button"
variant="outline"
size="xs"
className="mt-3 w-full"
onClick={() => onOpenTerminal(card)}
>
<SquareArrowOutUpRight className="size-3" />
{translate('dashboardPopout.rings.openLiveTerminal', 'Open live terminal')}
</Button>
</section>
<section className="mt-auto pt-4">
<div className="overflow-hidden rounded-lg border border-border bg-card shadow-xs">
<label
htmlFor={`fleet-response-${card.paneKey}`}
className="block border-b border-border px-2.5 py-2 text-[11px] font-semibold"
>
{translate('dashboardPopout.rings.respondToAgent', 'Respond to agent')}
</label>
<textarea
id={`fleet-response-${card.paneKey}`}
value={draft}
onChange={(event) => onDraftChange(event.target.value)}
onKeyDown={(event) => {
const modifier = isMac ? event.metaKey : event.ctrlKey
if (event.key === 'Enter' && modifier) {
event.preventDefault()
void sendDraft()
}
}}
placeholder={translate('dashboardPopout.rings.responsePlaceholder', 'Send direction…')}
className="scrollbar-sleek min-h-24 w-full resize-none bg-transparent px-2.5 py-2 text-xs outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
/>
<footer className="flex items-center gap-2 border-t border-border px-2 py-1.5">
{sendError ? (
<span className="text-[10px] text-destructive">
{translate('dashboardPopout.rings.sendFailed', 'Send failed')}
</span>
) : (
<span className="text-[10px] text-muted-foreground">
{card.ptyId
? translate('dashboardPopout.rings.routesToTerminal', 'Routes to live terminal')
: translate('dashboardPopout.rings.terminalUnavailable', 'Terminal unavailable')}
</span>
)}
<Button
type="button"
size="xs"
className="ml-auto"
disabled={!card.ptyId || !draft.trim() || sending}
onClick={() => void sendDraft()}
>
<Send className="size-3" />
{sending
? translate('dashboardPopout.rings.sending', 'Sending…')
: translate('dashboardPopout.rings.send', 'Send')}
<ShortcutKeyCombo
keys={[isMac ? '⌘' : 'Ctrl', '↵']}
keyCapClassName="min-w-4 border-primary-foreground/20 bg-primary-foreground/10 px-1 py-0 text-[9px] text-primary-foreground shadow-none"
separatorClassName="text-primary-foreground/70"
/>
</Button>
</footer>
</div>
</section>
</aside>
)
}
@@ -0,0 +1,394 @@
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { Focus, Minus, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import type {
FleetAgentNode,
FleetProjectRing,
FleetRingsLayout,
FleetWorktreeRing
} from './fleet-rings-layout'
import { shouldAggregateFleetWorktree } from './fleet-rings-layout'
import { FleetRingsMap } from './FleetRingsMap'
const MIN_ZOOM = 0.7
const MAX_ZOOM = 4
type Point = { x: number; y: number }
type ViewportSize = { width: number; height: number }
type Viewport = { center: Point; zoom: number }
export type FleetRingsCanvasHandle = {
fit: () => void
focusProject: (project: FleetProjectRing) => void
}
type FleetRingsCanvasProps = {
layout: FleetRingsLayout
selectedPaneKey: string | null
allowAggregation: boolean
onSelectAgent: (card: DashboardCard, side: 'left' | 'right') => void
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value))
}
function allAgents(layout: FleetRingsLayout): FleetAgentNode[] {
return layout.projects.flatMap((project) =>
project.worktrees.flatMap((worktree) => worktree.agents)
)
}
function nextDirectionalAgent(
current: FleetAgentNode,
agents: FleetAgentNode[],
direction: Point
): FleetAgentNode | null {
let best: { agent: FleetAgentNode; score: number } | null = null
for (const candidate of agents) {
if (candidate.card.paneKey === current.card.paneKey) {
continue
}
const dx = candidate.x - current.x
const dy = candidate.y - current.y
const forward = dx * direction.x + dy * direction.y
if (forward <= 0) {
continue
}
const sideways = Math.abs(dx * direction.y - dy * direction.x)
const score = forward + sideways * 2
if (!best || score < best.score) {
best = { agent: candidate, score }
}
}
return best?.agent ?? null
}
export const FleetRingsCanvas = forwardRef<FleetRingsCanvasHandle, FleetRingsCanvasProps>(
function FleetRingsCanvas(
{ layout, selectedPaneKey, allowAggregation, onSelectAgent },
forwardedRef
): React.JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const nodeRefs = useRef(new Map<string, SVGGElement>())
const dragRef = useRef<{
pointerId: number
point: Point
center: Point
worldPerPixelX: number
worldPerPixelY: number
} | null>(null)
const viewportFrameRef = useRef<number | null>(null)
const pendingViewportRef = useRef<Viewport | null>(null)
const interactionBoundsRef = useRef<DOMRect | null>(null)
const focusedPaneKeyRef = useRef<string | null>(null)
const [size, setSize] = useState<ViewportSize>({ width: 800, height: 560 })
const [viewport, setViewport] = useState<Viewport>({
center: { x: layout.width / 2, y: layout.height / 2 },
zoom: 1
})
const viewportRef = useRef(viewport)
viewportRef.current = viewport
const { center, zoom } = viewport
const agents = useMemo(() => allAgents(layout), [layout])
const aspect = size.width / Math.max(1, size.height)
const baseWidth = Math.max(layout.width, layout.height * aspect)
const baseHeight = baseWidth / aspect
const viewWidth = baseWidth / zoom
const viewHeight = baseHeight / zoom
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 applyViewport = (next: Viewport): void => {
viewportRef.current = next
pendingViewportRef.current = null
interactionBoundsRef.current = null
setViewport(next)
}
const scheduleViewport = (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 fit = (): void => {
applyViewport({ center: { x: layout.width / 2, y: layout.height / 2 }, zoom: 1 })
}
const focusProject = (project: FleetProjectRing): void => {
const projectWidth = project.radius * 2.5
const projectHeight = project.radius * 2.5
applyViewport({
center: { x: project.x, y: project.y },
zoom: clamp(
Math.min(baseWidth / projectWidth, baseHeight / projectHeight),
MIN_ZOOM,
MAX_ZOOM
)
})
}
const focusWorktree = (worktree: FleetWorktreeRing): void => {
applyViewport({
center: { x: worktree.x, y: worktree.y },
zoom: clamp(
Math.min(baseWidth / (worktree.radius * 2.7), baseHeight / (worktree.radius * 2.7)),
MIN_ZOOM,
MAX_ZOOM
)
})
}
useImperativeHandle(forwardedRef, () => ({ fit, focusProject }))
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) {
cancelAnimationFrame(viewportFrameRef.current)
}
},
[]
)
useEffect(() => {
if (!selectedPaneKey) {
focusedPaneKeyRef.current = null
return
}
if (focusedPaneKeyRef.current === selectedPaneKey) {
return
}
const selected = agents.find((agent) => agent.card.paneKey === selectedPaneKey)
if (!selected) {
return
}
focusedPaneKeyRef.current = selectedPaneKey
applyViewport({
center: { x: selected.x, y: selected.y },
zoom: Math.max(1, viewportRef.current.zoom)
})
}, [agents, selectedPaneKey])
const handleAgentKeyDown = (
event: React.KeyboardEvent<SVGGElement>,
agent: FleetAgentNode,
worktree: FleetWorktreeRing
): 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)
return
}
const direction =
event.key === 'ArrowLeft'
? { x: -1, y: 0 }
: event.key === 'ArrowRight'
? { x: 1, y: 0 }
: event.key === 'ArrowUp'
? { x: 0, y: -1 }
: event.key === 'ArrowDown'
? { x: 0, y: 1 }
: null
if (!direction) {
return
}
event.preventDefault()
const visibleAgents = shouldAggregateFleetWorktree(worktree, zoom, allowAggregation)
? agents.filter(
(candidate) =>
!worktree.agents.some(
(worktreeAgent) => worktreeAgent.card.paneKey === candidate.card.paneKey
)
)
: agents
const next = nextDirectionalAgent(agent, visibleAgents, direction)
nodeRefs.current.get(next?.card.paneKey ?? '')?.focus()
}
const zoomAt = (nextZoom: number, clientX?: number, clientY?: number): void => {
const clampedZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM)
if (clientX === undefined || clientY === undefined) {
applyViewport({ ...viewportRef.current, zoom: clampedZoom })
return
}
const bounds = interactionBoundsRef.current ?? svgRef.current?.getBoundingClientRect() ?? null
if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
applyViewport({ ...viewportRef.current, zoom: clampedZoom })
return
}
interactionBoundsRef.current = bounds
const current = viewportRef.current
const currentWidth = baseWidth / current.zoom
const currentHeight = baseHeight / current.zoom
const anchorX =
current.center.x -
currentWidth / 2 +
((clientX - bounds.left) / bounds.width) * currentWidth
const anchorY =
current.center.y -
currentHeight / 2 +
((clientY - bounds.top) / bounds.height) * currentHeight
const nextWidth = baseWidth / clampedZoom
const nextHeight = baseHeight / clampedZoom
const xRatio = (clientX - bounds.left) / bounds.width
const yRatio = (clientY - bounds.top) / bounds.height
scheduleViewport({
center: {
x: anchorX - (xRatio - 0.5) * nextWidth,
y: anchorY - (yRatio - 0.5) * nextHeight
},
zoom: clampedZoom
})
}
return (
<div
ref={containerRef}
className="fleet-rings-canvas relative min-h-0 flex-1 overflow-hidden"
>
{layout.projects.length === 0 ? (
<div className="absolute inset-0 grid place-items-center text-center text-xs text-muted-foreground">
{translate('dashboardPopout.rings.empty', 'No agents match the current filters.')}
</div>
) : (
<svg
ref={svgRef}
className="absolute inset-0 size-full touch-none"
viewBox={viewBox}
aria-label={translate(
'dashboardPopout.rings.canvasLabel',
'Nested project, workspace, and agent map'
)}
onWheel={(event) => {
event.preventDefault()
zoomAt(
viewportRef.current.zoom * Math.exp(-event.deltaY * 0.0015),
event.clientX,
event.clientY
)
}}
onPointerDown={(event) => {
if ((event.target as Element).closest('[data-fleet-agent]')) {
return
}
const bounds = event.currentTarget.getBoundingClientRect()
if (bounds.width <= 0 || bounds.height <= 0) {
return
}
const current = viewportRef.current
dragRef.current = {
pointerId: event.pointerId,
point: { x: event.clientX, y: event.clientY },
center: current.center,
worldPerPixelX: baseWidth / current.zoom / bounds.width,
worldPerPixelY: baseHeight / current.zoom / bounds.height
}
event.currentTarget.setPointerCapture(event.pointerId)
}}
onPointerMove={(event) => {
const drag = dragRef.current
if (!drag || drag.pointerId !== event.pointerId) {
return
}
scheduleViewport({
center: {
x: drag.center.x - (event.clientX - drag.point.x) * drag.worldPerPixelX,
y: drag.center.y - (event.clientY - drag.point.y) * drag.worldPerPixelY
},
zoom: viewportRef.current.zoom
})
}}
onPointerUp={(event) => {
if (dragRef.current?.pointerId === event.pointerId) {
dragRef.current = null
event.currentTarget.releasePointerCapture(event.pointerId)
}
}}
onPointerCancel={(event) => {
if (dragRef.current?.pointerId === event.pointerId) {
dragRef.current = null
}
}}
>
<FleetRingsMap
layout={layout}
zoom={zoom}
labelScale={labelScale}
mapScale={mapScale}
selectedPaneKey={selectedPaneKey}
allowAggregation={allowAggregation}
nodeRefs={nodeRefs}
onSelectAgent={onSelectAgent}
onFocusWorktree={focusWorktree}
onAgentKeyDown={handleAgentKeyDown}
/>
</svg>
)}
<div className="absolute bottom-3 left-3 flex items-center gap-0.5 rounded-md border border-border bg-card p-0.5 shadow-xs">
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate('dashboardPopout.rings.zoomOut', 'Zoom out')}
onClick={() => zoomAt(viewportRef.current.zoom / 1.25)}
>
<Minus className="size-3" />
</Button>
<span className="min-w-12 text-center text-[10px] text-muted-foreground tabular-nums">
{Math.round(zoom * 100)}%
</span>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate('dashboardPopout.rings.zoomIn', 'Zoom in')}
onClick={() => zoomAt(viewportRef.current.zoom * 1.25)}
>
<Plus className="size-3" />
</Button>
<Button type="button" variant="ghost" size="xs" onClick={fit}>
<Focus className="size-3" />
{translate('dashboardPopout.rings.fit', 'Fit')}
</Button>
</div>
</div>
)
}
)
@@ -0,0 +1,226 @@
import { Check, Focus, Pin } from 'lucide-react'
import { AgentStateDot } from '@/components/AgentStateDot'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import type {
FleetFinishedScope,
FleetFocusCounts,
FleetFocusState
} from './fleet-rings-focus-filter'
type FleetProjectOption = {
id: string
name: string
count: number
}
type FleetRingsFocusRailProps = {
counts: FleetFocusCounts
enabledStates: ReadonlySet<FleetFocusState>
finishedScope: FleetFinishedScope
hiddenProjectIds: ReadonlySet<string>
pinnedCount: number
projects: FleetProjectOption[]
reviewableVisibleCards: DashboardCard[]
totalCount: number
visibleCount: number
onFinishedScopeChange: (scope: FleetFinishedScope) => void
onFit: () => void
onMarkReviewed: (cards: DashboardCard[]) => void
onProjectToggle: (projectId: string) => void
onShowAll: () => void
onStateToggle: (state: FleetFocusState) => void
}
const STATE_ROWS: {
state: FleetFocusState
dotState: 'waiting' | 'working' | 'done'
}[] = [
{ state: 'attention', dotState: 'waiting' },
{ state: 'working', dotState: 'working' },
{ state: 'finished', dotState: 'done' }
]
const FINISHED_SCOPES: FleetFinishedScope[] = ['review', 'day', 'week', 'all']
function stateLabel(state: FleetFocusState): string {
switch (state) {
case 'attention':
return translate('dashboardPopout.bucket.attention', 'Needs You')
case 'working':
return translate('dashboardPopout.bucket.working', 'Working')
case 'finished':
return translate('dashboardPopout.rings.focus.finished', 'Finished')
}
}
function finishedScopeLabel(scope: FleetFinishedScope): string {
switch (scope) {
case 'review':
return translate('dashboardPopout.rings.focus.toReview', 'To review')
case 'day':
return translate('dashboardPopout.rings.focus.lastDay', 'Last 24 hours')
case 'week':
return translate('dashboardPopout.rings.focus.lastWeek', 'Last 7 days')
case 'all':
return translate('dashboardPopout.rings.focus.allFinished', 'All finished')
}
}
export function FleetRingsFocusRail({
counts,
enabledStates,
finishedScope,
hiddenProjectIds,
pinnedCount,
projects,
reviewableVisibleCards,
totalCount,
visibleCount,
onFinishedScopeChange,
onFit,
onMarkReviewed,
onProjectToggle,
onShowAll,
onStateToggle
}: FleetRingsFocusRailProps): React.JSX.Element {
return (
<aside className="scrollbar-sleek hidden min-h-0 w-56 shrink-0 overflow-y-auto border-r border-border bg-card/35 p-3 lg:flex lg:flex-col">
<header className="border-b border-border px-1 pb-3">
<span className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate('dashboardPopout.rings.focus.title', 'Focus view')}
</span>
<strong className="mt-1 block text-xs">
{translate('dashboardPopout.rings.focus.control', 'You decide what stays visible')}
</strong>
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">
{translate(
'dashboardPopout.rings.focus.reviewCopy',
'Opening a result marks it seen. Only Mark reviewed removes it from Focus.'
)}
</p>
</header>
<section className="border-b border-border py-3">
<span className="px-1 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate('dashboardPopout.rings.focus.showStates', 'Show agent states')}
</span>
<div className="mt-1.5 space-y-0.5">
{STATE_ROWS.map(({ state, dotState }) => {
const active = enabledStates.has(state)
return (
<button
key={state}
type="button"
aria-pressed={active}
onClick={() => onStateToggle(state)}
className={cn(
'grid h-8 w-full grid-cols-[14px_minmax(0,1fr)_auto] items-center gap-2 rounded-md px-2 text-left text-xs text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
active && 'bg-accent text-accent-foreground'
)}
>
<span className="inline-flex" aria-hidden>
<AgentStateDot state={dotState} size="md" />
</span>
<span>{stateLabel(state)}</span>
<strong className="text-[11px] font-medium tabular-nums">{counts[state]}</strong>
</button>
)
})}
</div>
</section>
<section className="border-b border-border py-3">
<span className="px-1 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate('dashboardPopout.rings.focus.finishedResults', 'Finished results')}
</span>
<div className="mt-1.5 space-y-0.5">
{FINISHED_SCOPES.map((scope) => (
<button
key={scope}
type="button"
disabled={!enabledStates.has('finished')}
onClick={() => onFinishedScopeChange(scope)}
className={cn(
'flex h-7 w-full items-center rounded-md px-2 text-left text-[11px] text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:pointer-events-none disabled:opacity-40',
finishedScope === scope && 'bg-accent text-accent-foreground'
)}
>
<span>{finishedScopeLabel(scope)}</span>
<strong className="ml-auto font-medium tabular-nums">{counts[scope]}</strong>
</button>
))}
</div>
<p className="mt-2 flex items-center gap-1.5 px-1 text-[10px] text-muted-foreground">
<Pin className="size-3" aria-hidden />
{translate('dashboardPopout.rings.focus.pinnedVisible', 'Pinned results stay visible.')}
{pinnedCount > 0 ? <strong className="ml-auto tabular-nums">{pinnedCount}</strong> : null}
</p>
</section>
<section className="border-b border-border py-3">
<span className="px-1 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate('dashboardPopout.filters.project', 'Project')}
</span>
<div className="mt-1.5 space-y-0.5">
{projects.map((project) => {
const active = !hiddenProjectIds.has(project.id)
return (
<button
key={project.id}
type="button"
aria-pressed={active}
onClick={() => onProjectToggle(project.id)}
className={cn(
'grid min-h-8 w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-2 rounded-md px-2 text-left text-xs text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
active && 'bg-accent text-accent-foreground'
)}
>
<span className="truncate font-medium">{project.name}</span>
<strong className="text-[11px] font-medium tabular-nums">{project.count}</strong>
</button>
)
})}
</div>
</section>
<div className="border-b border-border px-1 py-3">
<strong className="block text-xs tabular-nums">
{translate('dashboardPopout.search.results', '{{shown}} of {{total}} shown', {
shown: visibleCount,
total: totalCount
})}
</strong>
<span className="text-[10px] text-muted-foreground">
{translate('dashboardPopout.rings.focus.hidden', '{{count}} agents hidden', {
count: totalCount - visibleCount
})}
</span>
</div>
<Button
type="button"
variant="outline"
size="xs"
className="mt-3 justify-start"
disabled={reviewableVisibleCards.length === 0}
onClick={() => onMarkReviewed(reviewableVisibleCards)}
>
<Check className="size-3" />
{translate('dashboardPopout.rings.focus.reviewVisible', 'Mark visible results reviewed')}
</Button>
<div className="mt-auto grid grid-cols-2 gap-1 pt-3">
<Button type="button" variant="ghost" size="xs" onClick={onShowAll}>
{translate('dashboardPopout.rings.focus.showAll', 'Show all')}
</Button>
<Button type="button" variant="outline" size="xs" onClick={onFit}>
<Focus className="size-3" />
{translate('dashboardPopout.rings.fit', 'Fit')}
</Button>
</div>
</aside>
)
}
@@ -0,0 +1,157 @@
import { Focus, Layers3 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import type { DashboardCardDotState } from '../../../../shared/dashboard-snapshot'
import type { FleetProjectRing, FleetRingsLayout } from './fleet-rings-layout'
type FleetRingsLegendProps = {
layout: FleetRingsLayout
onFocusProject: (project: FleetProjectRing) => void
onFit: () => void
}
const STATUS_ROWS: DashboardCardDotState[] = ['waiting', 'blocked', 'working', 'done', 'idle']
function stateLabel(state: DashboardCardDotState): string {
switch (state) {
case 'waiting':
return translate('dashboardPopout.rings.state.waiting', 'Waiting for input')
case 'blocked':
return translate('dashboardPopout.rings.state.blocked', 'Blocked')
case 'working':
return translate('dashboardPopout.rings.state.working', 'Working')
case 'done':
return translate('dashboardPopout.rings.state.done', 'Done')
case 'idle':
return translate('dashboardPopout.rings.state.idle', 'Idle')
}
}
export function FleetRingsLegend({
layout,
onFocusProject,
onFit
}: FleetRingsLegendProps): React.JSX.Element {
const counts = { working: 0, blocked: 0, waiting: 0, done: 0, idle: 0 }
for (const project of layout.projects) {
for (const worktree of project.worktrees) {
for (const state of Object.keys(counts) as DashboardCardDotState[]) {
counts[state] += worktree.statusCounts[state]
}
}
}
return (
<aside className="scrollbar-sleek hidden min-h-0 w-52 shrink-0 overflow-y-auto border-r border-border bg-card/35 p-3 lg:flex lg:flex-col">
<header className="border-b border-border px-1 pb-3">
<span className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
<Layers3 className="size-3" aria-hidden />
{translate('dashboardPopout.rings.canvasLayers', 'Canvas layers')}
</span>
<strong className="mt-1 block text-xs">
{translate('dashboardPopout.rings.hierarchy', 'Project → workspace → agent')}
</strong>
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">
{translate(
'dashboardPopout.rings.hierarchyCopy',
'Every agent keeps a stable place in its ownership tree.'
)}
</p>
</header>
<section className="border-b border-border py-3">
<span className="px-1 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate('dashboardPopout.filters.project', 'Project')}
</span>
<div className="mt-1.5 space-y-1">
{layout.projects.map((project) => (
<button
key={project.id}
type="button"
onClick={() => onFocusProject(project)}
className="grid w-full grid-cols-[minmax(0,1fr)_auto] rounded-md px-2 py-1.5 text-left hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<span className="min-w-0">
<strong className="block truncate text-xs">{project.name}</strong>
<span className="block text-[10px] text-muted-foreground">
{translate('dashboardPopout.rings.workspaceCount', '{{count}} workspaces', {
count: project.worktrees.length
})}
</span>
</span>
<span className="self-center text-xs font-semibold tabular-nums">
{project.agentCount}
</span>
</button>
))}
</div>
</section>
<section className="border-b border-border py-3">
<span className="px-1 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate('dashboardPopout.rings.agentState', 'Agent state')}
</span>
<div className="mt-2 space-y-2">
{STATUS_ROWS.map((state) => (
<div
key={state}
className="grid grid-cols-[12px_minmax(0,1fr)_auto] items-center gap-1.5 px-1 text-[11px] text-muted-foreground"
>
<span className={`fleet-legend-dot fleet-status-${state}`} aria-hidden />
<span>{stateLabel(state)}</span>
<span className="text-foreground tabular-nums">{counts[state]}</span>
</div>
))}
</div>
</section>
<section className="border-b border-border py-3">
<span className="px-1 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate('dashboardPopout.rings.agentSize', 'Agent size')}
</span>
<div className="mt-2 flex h-10 items-center justify-around rounded-md bg-muted">
{[4, 6.5, 9, 12].map((radius) => (
<span
key={radius}
className="rounded-full border border-muted-foreground"
style={{ width: radius * 2, height: radius * 2 }}
/>
))}
</div>
<div className="mt-1 flex justify-between text-[10px] text-muted-foreground">
<span>{translate('dashboardPopout.rings.justStarted', 'Just started')}</span>
<span>{translate('dashboardPopout.rings.workingLonger', 'Working longer')}</span>
</div>
<p className="mt-1 text-[10px] text-muted-foreground">
{translate('dashboardPopout.rings.sizeScale', 'Logarithmic scale · capped after 60m')}
</p>
</section>
<section className="space-y-2 px-1 py-3 text-[10px]">
<p className="flex justify-between">
<strong>{translate('dashboardPopout.rings.hover', 'Hover')}</strong>
<span className="text-muted-foreground">
{translate('dashboardPopout.rings.previewTerminal', 'Preview terminal')}
</span>
</p>
<p className="flex justify-between">
<strong>{translate('dashboardPopout.rings.click', 'Click')}</strong>
<span className="text-muted-foreground">
{translate('dashboardPopout.rings.pinAgent', 'Pin agent')}
</span>
</p>
<p className="flex justify-between">
<strong>{translate('dashboardPopout.rings.doubleClick', 'Double-click')}</strong>
<span className="text-muted-foreground">
{translate('dashboardPopout.rings.zoomWorkspace', 'Zoom workspace')}
</span>
</p>
</section>
<Button type="button" variant="outline" size="xs" className="mt-auto" onClick={onFit}>
<Focus className="size-3" />
{translate('dashboardPopout.rings.fitFleet', 'Fit fleet')}
</Button>
</aside>
)
}
@@ -0,0 +1,213 @@
import type { MutableRefObject } from 'react'
import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot'
import { translate } from '@/i18n/i18n'
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent } from '@/lib/agent-status'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import type { FleetAgentNode, FleetRingsLayout, FleetWorktreeRing } from './fleet-rings-layout'
import { shouldAggregateFleetWorktree } from './fleet-rings-layout'
type FleetRingsMapProps = {
layout: FleetRingsLayout
zoom: number
labelScale: number
mapScale: number
selectedPaneKey: string | null
allowAggregation: boolean
nodeRefs: MutableRefObject<Map<string, SVGGElement>>
onSelectAgent: (card: DashboardCard, side: 'left' | 'right') => void
onFocusWorktree: (worktree: FleetWorktreeRing) => void
onAgentKeyDown: (
event: React.KeyboardEvent<SVGGElement>,
agent: FleetAgentNode,
worktree: FleetWorktreeRing
) => void
}
function formatDuration(minutes: number): string {
if (minutes < 1) {
return translate('dashboardPopout.card.time.justNow', 'just now')
}
if (minutes < 60) {
return translate('dashboardPopout.card.time.minutes', '{{count}}m', {
count: Math.floor(minutes)
})
}
return translate('dashboardPopout.card.time.hours', '{{count}}h', {
count: Math.floor(minutes / 60)
})
}
function lineagePath(parent: FleetAgentNode, child: FleetAgentNode): string {
const startY = parent.y + parent.radius
const endY = child.y - child.radius
const branchY = (startY + endY) / 2
return `M ${parent.x} ${startY} L ${parent.x} ${branchY} L ${child.x} ${branchY} L ${child.x} ${endY}`
}
export function FleetRingsMap({
layout,
zoom,
labelScale,
mapScale,
selectedPaneKey,
allowAggregation,
nodeRefs,
onSelectAgent,
onFocusWorktree,
onAgentKeyDown
}: FleetRingsMapProps): React.JSX.Element {
return (
<>
{layout.projects.map((project) => (
<g key={project.id}>
<circle className="fleet-project-ring" cx={project.x} cy={project.y} r={project.radius} />
<g
transform={`translate(${project.x} ${project.y - project.radius}) scale(${labelScale})`}
>
<text className="fleet-project-label" y={18}>
{project.name.toUpperCase()}
</text>
<text className="fleet-project-count" y={32}>
{translate(
'dashboardPopout.rings.projectCount',
'{{agents}} agents · {{workspaces}} workspaces',
{
agents: project.agentCount,
workspaces: project.worktrees.length
}
).toUpperCase()}
</text>
</g>
{project.worktrees.map((worktree) => {
const aggregate = shouldAggregateFleetWorktree(worktree, zoom, allowAggregation)
const selected = worktree.agents.some((agent) => agent.card.paneKey === selectedPaneKey)
const agentsByPaneKey = new Map(
worktree.agents.map((agent) => [agent.card.paneKey, agent])
)
const screenRadius = worktree.radius * mapScale
const showLabel = !worktree.quiet || screenRadius >= 56
const showCount = screenRadius >= 80
return (
<g key={worktree.id} className="fleet-worktree-group">
<circle
className={`fleet-worktree-ring${selected ? ' is-selected' : ''}`}
cx={worktree.x}
cy={worktree.y}
r={worktree.radius}
onDoubleClick={() => onFocusWorktree(worktree)}
/>
<g
className={`fleet-worktree-label-group${showLabel ? ' is-visible' : ''}${showCount ? ' is-count-visible' : ''}`}
transform={`translate(${worktree.x} ${worktree.y - worktree.radius}) scale(${labelScale})`}
>
<text className="fleet-worktree-label" y={18}>
{worktree.name}
</text>
<text className="fleet-worktree-count" y={32}>
{translate('dashboardPopout.rings.agentCount', '{{count}} agents', {
count: worktree.agents.length
})}
</text>
</g>
{aggregate ? (
<g
className="fleet-aggregate-node"
transform={`translate(${worktree.x} ${worktree.y + 7})`}
>
<circle r={Math.min(26, 12 + Math.sqrt(worktree.agents.length) * 2)} />
<text y={3}>{worktree.agents.length}</text>
</g>
) : (
<>
<g className="fleet-agent-lineage-links" aria-hidden>
{worktree.agents.map((child) => {
const parent = child.card.parentPaneKey
? agentsByPaneKey.get(child.card.parentPaneKey)
: undefined
if (!parent || child.y <= parent.y) {
return null
}
return (
<path
key={child.card.paneKey}
className="fleet-agent-lineage-link"
data-fleet-agent-lineage-link=""
data-parent-pane-key={parent.card.paneKey}
data-child-pane-key={child.card.paneKey}
d={lineagePath(parent, child)}
/>
)
})}
</g>
{worktree.agents.map((agent) => {
const iconSize = Math.max(12, Math.min(22, agent.radius * 1.05))
const stateMarkerSize = 14
return (
<g
key={agent.card.paneKey}
ref={(node) => {
if (node) {
nodeRefs.current.set(agent.card.paneKey, node)
} else {
nodeRefs.current.delete(agent.card.paneKey)
}
}}
data-fleet-agent=""
data-agent-provider={agent.card.agentType}
role="button"
tabIndex={0}
aria-label={`${agent.card.conversationName ?? agent.card.agentType}, ${agentStateLabel(agent.status)}, ${formatDuration(agent.durationMinutes)}, ${worktree.name}, ${project.name}`}
className={`fleet-agent-node fleet-status-${agent.status}${selectedPaneKey === agent.card.paneKey ? ' is-selected' : ''}`}
transform={`translate(${agent.x} ${agent.y})`}
onClick={(event) => {
const bounds = event.currentTarget.getBoundingClientRect()
const side =
bounds.left + bounds.width / 2 <= window.innerWidth / 2
? 'right'
: 'left'
onSelectAgent(agent.card, side)
}}
onKeyDown={(event) => onAgentKeyDown(event, agent, worktree)}
>
<circle className="fleet-agent-hit" r={Math.max(10, agent.radius + 3)} />
<circle className="fleet-agent-mark" r={agent.radius} />
<foreignObject
className="fleet-agent-icon"
x={-iconSize / 2}
y={-iconSize / 2}
width={iconSize}
height={iconSize}
>
<div>
<AgentIcon
agent={agentTypeToIconAgent(agent.card.agentType)}
size={iconSize}
/>
</div>
</foreignObject>
<foreignObject
className="fleet-agent-state"
x={-agent.radius - 4}
y={-agent.radius - 4}
width={stateMarkerSize}
height={stateMarkerSize}
aria-hidden
>
<div>
<AgentStateDot state={agent.status} size="md" />
</div>
</foreignObject>
</g>
)
})}
</>
)}
</g>
)
})}
</g>
))}
</>
)
}
@@ -0,0 +1,60 @@
import { Terminal } from 'lucide-react'
import { agentStateLabel } from '@/components/AgentStateDot'
import { formatAgentTypeLabel } from '@/lib/agent-status'
import { translate } from '@/i18n/i18n'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { FleetTerminalStreamPreview } from './FleetTerminalStreamPreview'
type FleetTerminalHoverPreviewProps = {
card: DashboardCard
left: number
top: number
}
export function FleetTerminalHoverPreview({
card,
left,
top
}: FleetTerminalHoverPreviewProps): React.JSX.Element {
const name = card.conversationName ?? formatAgentTypeLabel(card.agentType)
return (
<section
role="status"
aria-live="polite"
className="pointer-events-none absolute z-20 w-72 overflow-hidden rounded-lg border border-ring bg-popover text-popover-foreground shadow-[0_10px_24px_rgba(0,0,0,0.18)]"
style={{ left, top }}
data-testid="fleet-terminal-hover-preview"
>
<header className="flex min-w-0 items-center gap-2 border-b border-border px-2.5 py-2">
<Terminal className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0">
<strong className="block truncate text-xs font-semibold">{name}</strong>
<span className="block truncate text-[11px] text-muted-foreground">
{card.worktreeName} · {agentStateLabel(card.dotState)}
</span>
</span>
<span className="ml-auto text-[10px] font-semibold uppercase tracking-[0.05em] text-status-success">
{card.ptyId
? translate('dashboardPopout.rings.live', 'Live')
: translate('dashboardPopout.rings.snapshot', 'Snapshot')}
</span>
</header>
{card.ptyId ? (
<FleetTerminalStreamPreview ptyId={card.ptyId} />
) : (
<p className="line-clamp-5 min-h-24 whitespace-pre-wrap px-2.5 py-2 font-mono text-[11px] leading-relaxed text-muted-foreground">
{card.lastAgentMessage ||
card.task ||
translate(
'dashboardPopout.terminal.closed',
"No live terminal — this agent's pane has closed."
)}
</p>
)}
<footer className="border-t border-border px-2.5 py-1.5 text-[10px] text-muted-foreground">
{translate('dashboardPopout.rings.clickToPin', 'Click agent to pin')}
</footer>
</section>
)
}
@@ -0,0 +1,116 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalPreviewDataPayload } from '../../../../shared/terminal-preview'
const terminalHarness = vi.hoisted(() => ({
instances: [] as {
options: Record<string, unknown>
write: ReturnType<typeof vi.fn>
open: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
resize: ReturnType<typeof vi.fn>
reset: ReturnType<typeof vi.fn>
}[]
}))
vi.mock('@xterm/xterm', () => ({
Terminal: class {
write = vi.fn((_data: string, callback?: () => void) => callback?.())
open = vi.fn()
dispose = vi.fn()
resize = vi.fn()
reset = vi.fn()
constructor(options: Record<string, unknown>) {
terminalHarness.instances.push({
options,
write: this.write,
open: this.open,
dispose: this.dispose,
resize: this.resize,
reset: this.reset
})
}
}
}))
import { FleetTerminalStreamPreview } from './FleetTerminalStreamPreview'
describe('FleetTerminalStreamPreview', () => {
const connect = vi.fn()
const ack = vi.fn(async () => {})
const unsubscribe = vi.fn(async () => {})
const input = vi.fn(async () => true)
const fit = vi.fn(async () => null)
const offData = vi.fn()
let emitData: ((payload: TerminalPreviewDataPayload) => void) | null
beforeEach(() => {
terminalHarness.instances.length = 0
emitData = null
connect.mockResolvedValue({
snapshot: {
data: 'screen',
cols: 80,
rows: 24,
seq: 1,
scrollbackAnsi: 'history'
},
replay: ['live replay']
})
vi.stubGlobal(
'requestAnimationFrame',
vi.fn(() => 1)
)
vi.stubGlobal('cancelAnimationFrame', vi.fn())
Object.assign(window, {
api: {
terminalPreview: {
connect,
ack,
unsubscribe,
input,
fit,
onData: (listener: (payload: TerminalPreviewDataPayload) => void) => {
emitData = listener
return offData
}
}
}
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
vi.unstubAllGlobals()
})
it('streams bounded read-only output without claiming or writing to the PTY', async () => {
const view = render(<FleetTerminalStreamPreview ptyId="pty-1" />)
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
const terminal = terminalHarness.instances[0]!
expect(connect).toHaveBeenCalledWith('pty-1', { scrollbackRows: 12 })
expect(terminal.options).toMatchObject({ disableStdin: true, scrollback: 100 })
expect(terminal.write).toHaveBeenCalledWith('history', expect.any(Function))
expect(terminal.write).toHaveBeenCalledWith('screen', expect.any(Function))
expect(terminal.write).toHaveBeenCalledWith('live replay', expect.any(Function))
act(() => {
emitData?.({ type: 'data', ptyId: 'pty-1', data: 'next frame', bytes: 10 })
})
expect(terminal.write).toHaveBeenCalledWith('next frame', expect.any(Function))
expect(ack).toHaveBeenCalledWith('pty-1', 10)
expect(fit).not.toHaveBeenCalled()
expect(input).not.toHaveBeenCalled()
view.unmount()
expect(offData).toHaveBeenCalledOnce()
expect(unsubscribe).toHaveBeenCalledWith('pty-1')
expect(terminal.dispose).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,171 @@
import { useEffect, useRef, useState } from 'react'
import { Terminal } from '@xterm/xterm'
import { translate } from '@/i18n/i18n'
import type { TerminalPreviewDataPayload } from '../../../../shared/terminal-preview'
const HOVER_SCROLLBACK_ROWS = 12
/** Read-only live stream for hover/focus. It never claims the PTY grid or forwards input. */
export function FleetTerminalStreamPreview({ ptyId }: { ptyId: string }): React.JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const [ptyGone, setPtyGone] = useState(false)
useEffect(() => {
const container = containerRef.current
if (!container) {
return
}
let disposed = false
let terminal: Terminal | null = null
let connecting = false
let reconnectRequested = false
let pending: Extract<TerminalPreviewDataPayload, { type: 'data' }>[] = []
let fitFrame: number | null = null
const fit = (): void => {
if (fitFrame !== null) {
return
}
fitFrame = requestAnimationFrame(() => {
fitFrame = null
const screen = container.querySelector<HTMLElement>('.xterm-screen')
if (!screen) {
return
}
const scale = Math.min(
1,
container.clientWidth / Math.max(1, screen.offsetWidth),
container.clientHeight / Math.max(1, screen.offsetHeight)
)
container.style.transform = scale < 1 ? `scale(${scale})` : ''
})
}
const write = (data: string, callback?: () => void): void => {
terminal?.write(data, () => {
fit()
callback?.()
})
}
const writeLive = (payload: Extract<TerminalPreviewDataPayload, { type: 'data' }>): void => {
if (!terminal) {
pending.push(payload)
return
}
write(payload.data, () => {
if (!disposed) {
void window.api.terminalPreview.ack(ptyId, payload.bytes)
}
})
}
const connect = async (replace = false): Promise<void> => {
if (connecting) {
reconnectRequested = true
return
}
connecting = true
const connection = await window.api.terminalPreview.connect(ptyId, {
scrollbackRows: HOVER_SCROLLBACK_ROWS
})
connecting = false
if (disposed) {
void window.api.terminalPreview.unsubscribe(ptyId)
return
}
const snapshot = connection.snapshot
if (!snapshot) {
setPtyGone(true)
terminal?.dispose()
terminal = null
return
}
if (!terminal) {
const styles = getComputedStyle(container)
terminal = new Terminal({
cols: Math.max(2, snapshot.cols),
rows: Math.max(2, snapshot.rows),
disableStdin: true,
cursorBlink: false,
scrollback: 100,
fontSize: 10,
fontFamily: styles.getPropertyValue('--font-mono').trim(),
theme: {
background: styles.getPropertyValue('--background').trim(),
foreground: styles.getPropertyValue('--foreground').trim()
}
})
terminal.open(container)
} else if (replace) {
terminal.resize(Math.max(2, snapshot.cols), Math.max(2, snapshot.rows))
terminal.reset()
}
for (const data of [
snapshot.scrollbackAnsi,
snapshot.data,
snapshot.pendingEscapeTailAnsi,
...connection.replay
]) {
if (data) {
write(data)
}
}
for (const payload of pending) {
writeLive(payload)
}
pending = []
if (connection.resyncRequired || reconnectRequested) {
reconnectRequested = false
void connect(true)
}
fit()
}
const offData = window.api.terminalPreview.onData((payload) => {
if (payload.ptyId !== ptyId) {
return
}
if (payload.type === 'resync') {
void connect(true)
} else {
writeLive(payload)
}
})
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(fit)
observer?.observe(container)
void connect()
return () => {
disposed = true
offData()
observer?.disconnect()
if (fitFrame !== null) {
cancelAnimationFrame(fitFrame)
}
void window.api.terminalPreview.unsubscribe(ptyId)
terminal?.dispose()
}
}, [ptyId])
return (
<div
className="relative h-32 overflow-hidden bg-background"
aria-label={translate('dashboardPopout.rings.liveTerminalPreview', 'Live terminal preview')}
>
{ptyGone ? (
<div className="absolute inset-0 grid place-items-center px-3 text-center text-[11px] text-muted-foreground">
{translate(
'dashboardPopout.terminal.closed',
"No live terminal — this agent's pane has closed."
)}
</div>
) : null}
<div
ref={containerRef}
aria-hidden={ptyGone || undefined}
className="h-full w-full origin-top-left p-1.5"
/>
</div>
)
}
@@ -0,0 +1,133 @@
.agent-cell-map {
background-image: radial-gradient(
color-mix(in srgb, var(--muted-foreground) 12%, transparent) 0.6px,
transparent 0.6px
);
background-size: 20px 20px;
}
.agent-cell-projects {
display: flex;
min-height: 100%;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.agent-cell-project {
min-width: min(100%, 28rem);
flex-basis: 28rem;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: color-mix(in srgb, var(--card) 70%, transparent);
}
.agent-cell-worktrees {
display: grid;
padding: 8px;
gap: 8px;
grid-auto-flow: dense;
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
}
.agent-cell-worktree {
display: flex;
min-height: 76px;
flex-direction: column;
gap: 10px;
padding: 10px;
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--card) 88%, transparent);
}
.agent-cell-worktree.is-medium,
.agent-cell-worktree.is-large {
grid-column: span 2;
}
.agent-cell-worktree.has-attention {
border-color: color-mix(in srgb, var(--color-amber-500) 42%, var(--border));
background: color-mix(in srgb, var(--color-amber-500) 5%, var(--card));
}
.agent-cell-worktree.has-working {
border-color: color-mix(in srgb, var(--color-yellow-500) 34%, var(--border));
}
.agent-cell-worktree.has-done {
border-color: color-mix(in srgb, var(--color-emerald-500) 26%, var(--border));
}
.agent-cell-worktree.is-selected {
outline: 1px solid var(--ring);
outline-offset: -1px;
background: color-mix(in srgb, var(--accent) 55%, var(--card));
}
.agent-cell-node {
position: relative;
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: 1.5px solid var(--border);
border-radius: 9999px;
outline: none;
background: var(--background);
color: var(--foreground);
}
.agent-cell-node:hover,
.agent-cell-node:focus-visible {
box-shadow: 0 0 0 2px var(--ring);
}
.agent-cell-node.fleet-status-working {
border-color: var(--color-yellow-500);
box-shadow: 0 0 6px color-mix(in srgb, var(--color-yellow-500) 68%, transparent);
}
.agent-cell-node.fleet-status-blocked {
border-color: var(--color-red-500);
box-shadow: 0 0 6px color-mix(in srgb, var(--color-red-500) 68%, transparent);
}
.agent-cell-node.fleet-status-waiting {
border-color: var(--color-amber-500);
box-shadow: 0 0 6px color-mix(in srgb, var(--color-amber-500) 68%, transparent);
}
.agent-cell-node.fleet-status-done {
border-color: var(--color-emerald-500);
box-shadow: 0 0 6px color-mix(in srgb, var(--color-emerald-500) 68%, transparent);
}
.agent-cell-node.fleet-status-idle {
border-color: color-mix(in srgb, var(--color-neutral-500) 55%, transparent);
}
.agent-cell-node[data-selected='true'] {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.agent-cell-node-state {
position: absolute;
top: -3px;
left: -3px;
display: grid;
width: 14px;
height: 14px;
place-items: center;
border-radius: 9999px;
background: var(--background);
}
@media (max-width: 720px) {
.agent-cell-worktree.is-medium,
.agent-cell-worktree.is-large {
grid-column: span 1;
}
}
@@ -0,0 +1,149 @@
import { formatAgentTypeLabel } from '@/lib/agent-status'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
export const DASHBOARD_ORCHESTRATOR_CONTEXT_MIME =
'application/x-orca-dashboard-orchestrator-context'
export type DashboardOrchestratorContext = {
id: string
kind: 'project' | 'workspace' | 'agent'
label: string
projectName: string
workspaceName?: string
agentName?: string
}
export type DashboardOrchestratorWorkspace = {
id: string
name: string
context: DashboardOrchestratorContext
cards: DashboardCard[]
}
export type DashboardOrchestratorProject = {
id: string
name: string
context: DashboardOrchestratorContext
workspaces: DashboardOrchestratorWorkspace[]
agentCount: number
}
function cardAgentName(card: DashboardCard): string {
return card.conversationName ?? formatAgentTypeLabel(card.agentType)
}
function cleanContextLabel(value: string): string {
return value.replace(/\s+/g, ' ').trim()
}
export function buildDashboardOrchestratorProjects(
cards: DashboardCard[]
): DashboardOrchestratorProject[] {
const projects = new Map<
string,
{
name: string
workspaces: Map<string, { name: string; cards: DashboardCard[] }>
}
>()
for (const card of cards) {
const project = projects.get(card.repoId) ?? {
name: card.repoName,
workspaces: new Map<string, { name: string; cards: DashboardCard[] }>()
}
const workspace = project.workspaces.get(card.worktreeId) ?? {
name: card.worktreeName,
cards: []
}
workspace.cards.push(card)
project.workspaces.set(card.worktreeId, workspace)
projects.set(card.repoId, project)
}
return [...projects.entries()]
.map(([projectId, project]): DashboardOrchestratorProject => {
const projectName = cleanContextLabel(project.name)
const workspaces = [...project.workspaces.entries()]
.map(([workspaceId, workspace]): DashboardOrchestratorWorkspace => {
const workspaceName = cleanContextLabel(workspace.name)
return {
id: workspaceId,
name: workspaceName,
context: {
id: `workspace:${workspaceId}`,
kind: 'workspace',
label: workspaceName,
projectName,
workspaceName
},
cards: [...workspace.cards].sort(
(left, right) => right.stateChangedAt - left.stateChangedAt
)
}
})
.sort((left, right) => left.name.localeCompare(right.name))
return {
id: projectId,
name: projectName,
context: {
id: `project:${projectId}`,
kind: 'project',
label: projectName,
projectName
},
workspaces,
agentCount: workspaces.reduce((count, workspace) => count + workspace.cards.length, 0)
}
})
.sort((left, right) => left.name.localeCompare(right.name))
}
export function dashboardAgentContext(
card: DashboardCard,
projectName: string,
workspaceName: string
): DashboardOrchestratorContext {
const agentName = cleanContextLabel(cardAgentName(card))
return {
id: `agent:${card.paneKey}`,
kind: 'agent',
label: agentName,
projectName,
workspaceName,
agentName
}
}
export function listDashboardOrchestratorContexts(
projects: DashboardOrchestratorProject[]
): DashboardOrchestratorContext[] {
return projects.flatMap((project) => [
project.context,
...project.workspaces.flatMap((workspace) => [
workspace.context,
...workspace.cards.map((card) => dashboardAgentContext(card, project.name, workspace.name))
])
])
}
function describeContext(context: DashboardOrchestratorContext): string {
if (context.kind === 'project') {
return `project "${context.projectName}"`
}
if (context.kind === 'workspace') {
return `workspace "${context.workspaceName}" in project "${context.projectName}"`
}
return `agent "${context.agentName}" in workspace "${context.workspaceName}"`
}
export function buildDashboardOrchestrationPrompt(
message: string,
contexts: DashboardOrchestratorContext[]
): string {
const scope =
contexts.length === 0
? 'Coordinate this request across the entire Orca fleet.'
: `Coordinate this request with these selected fleet participants: ${contexts.map(describeContext).join('; ')}.`
return `$orchestration ${scope}\n\n${message}`
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import {
appendDashboardOrchestratorMessage,
MAX_DASHBOARD_ORCHESTRATOR_MESSAGE_BYTES,
MAX_DASHBOARD_ORCHESTRATOR_MESSAGES,
type DashboardOrchestratorMessage
} from './dashboard-orchestrator-message-history'
function append(
current: DashboardOrchestratorMessage[],
id: number,
text = `message-${id}`
): DashboardOrchestratorMessage[] {
return appendDashboardOrchestratorMessage(current, { id, role: 'user', text })
}
describe('dashboard orchestrator message history', () => {
it('retains only the newest bounded message count', () => {
let history: DashboardOrchestratorMessage[] = []
for (let id = 1; id <= MAX_DASHBOARD_ORCHESTRATOR_MESSAGES + 10; id += 1) {
history = append(history, id)
}
expect(history).toHaveLength(MAX_DASHBOARD_ORCHESTRATOR_MESSAGES)
expect(history[0]?.id).toBe(11)
})
it('bounds retained UTF-8 bytes and oversized individual messages', () => {
const large = 'x'.repeat(Math.ceil(MAX_DASHBOARD_ORCHESTRATOR_MESSAGE_BYTES * 0.6))
const first = append([], 1, large)
const second = append(first, 2, large)
expect(second).toHaveLength(1)
expect(second[0]?.id).toBe(2)
const oversized = append([], 3, '界'.repeat(MAX_DASHBOARD_ORCHESTRATOR_MESSAGE_BYTES))
const retainedBytes = oversized.reduce((total, message) => total + message.byteLength, 0)
expect(retainedBytes).toBeLessThanOrEqual(MAX_DASHBOARD_ORCHESTRATOR_MESSAGE_BYTES)
expect(oversized[0]?.text.endsWith('…')).toBe(true)
})
})
@@ -0,0 +1,46 @@
export type DashboardOrchestratorMessage = {
id: number
role: 'user' | 'assistant'
text: string
byteLength: number
}
export const MAX_DASHBOARD_ORCHESTRATOR_MESSAGES = 200
export const MAX_DASHBOARD_ORCHESTRATOR_MESSAGE_BYTES = 1024 * 1024
const encoder = new TextEncoder()
const TRUNCATION_MARKER = '…'
const TRUNCATION_MARKER_BYTES = encoder.encode(TRUNCATION_MARKER).byteLength
function boundMessageText(text: string): { text: string; byteLength: number } {
const encoded = encoder.encode(text)
if (encoded.byteLength <= MAX_DASHBOARD_ORCHESTRATOR_MESSAGE_BYTES) {
return { text, byteLength: encoded.byteLength }
}
const prefix = new TextDecoder().decode(
encoded.subarray(0, MAX_DASHBOARD_ORCHESTRATOR_MESSAGE_BYTES - TRUNCATION_MARKER_BYTES),
{ stream: true }
)
return {
text: `${prefix}${TRUNCATION_MARKER}`,
byteLength: encoder.encode(prefix).byteLength + TRUNCATION_MARKER_BYTES
}
}
export function appendDashboardOrchestratorMessage(
current: DashboardOrchestratorMessage[],
message: Omit<DashboardOrchestratorMessage, 'byteLength'>
): DashboardOrchestratorMessage[] {
const bounded = boundMessageText(message.text)
const next = [...current, { ...message, ...bounded }]
let retainedBytes = next.reduce((total, entry) => total + entry.byteLength, 0)
let removeCount = 0
while (
next.length - removeCount > MAX_DASHBOARD_ORCHESTRATOR_MESSAGES ||
retainedBytes > MAX_DASHBOARD_ORCHESTRATOR_MESSAGE_BYTES
) {
retainedBytes -= next[removeCount]!.byteLength
removeCount += 1
}
return removeCount === 0 ? next : next.slice(removeCount)
}
@@ -0,0 +1,130 @@
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { packFleetWorktrees } from './fleet-worktree-packing'
const HORIZONTAL_GAP = 58
const VERTICAL_GAP = 62
const FAMILY_PADDING = 12
const WORKTREE_PADDING = 10
export type FleetLineageAgentPosition = {
card: DashboardCard
x: number
y: number
}
type FleetAgentFamily = {
id: string
x: number
y: number
radius: number
agents: FleetLineageAgentPosition[]
}
function compareStable(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0
}
function buildFamily(
root: DashboardCard,
childrenByParent: ReadonlyMap<string, DashboardCard[]>,
nodeRadius: number,
emitted: Set<string>
): FleetAgentFamily {
const agents: FleetLineageAgentPosition[] = []
let leafIndex = 0
const placeSubtree = (
card: DashboardCard,
depth: number,
ancestors: ReadonlySet<string>
): number => {
if (ancestors.has(card.paneKey) || emitted.has(card.paneKey)) {
return leafIndex++ * HORIZONTAL_GAP
}
emitted.add(card.paneKey)
const nextAncestors = new Set(ancestors)
nextAncestors.add(card.paneKey)
const children = (childrenByParent.get(card.paneKey) ?? []).filter(
(child) => !nextAncestors.has(child.paneKey) && !emitted.has(child.paneKey)
)
const childXs = children.map((child) => placeSubtree(child, depth + 1, nextAncestors))
const x =
childXs.length > 0
? (Math.min(...childXs) + Math.max(...childXs)) / 2
: leafIndex++ * HORIZONTAL_GAP
agents.push({ card, x, y: depth * VERTICAL_GAP })
return x
}
placeSubtree(root, 0, new Set())
const centerX =
(Math.min(...agents.map((agent) => agent.x)) + Math.max(...agents.map((agent) => agent.x))) / 2
const centerY =
(Math.min(...agents.map((agent) => agent.y)) + Math.max(...agents.map((agent) => agent.y))) / 2
for (const agent of agents) {
agent.x -= centerX
agent.y -= centerY
}
return {
id: root.paneKey,
x: 0,
y: 0,
radius:
Math.max(...agents.map((agent) => Math.hypot(agent.x, agent.y))) +
nodeRadius +
FAMILY_PADDING,
agents
}
}
export function layoutFleetAgentLineage(
cards: DashboardCard[],
nodeRadius: number
): { agents: FleetLineageAgentPosition[]; radius: number } | null {
const sorted = [...cards].sort((a, b) => compareStable(a.paneKey, b.paneKey))
const cardsByPaneKey = new Map(sorted.map((card) => [card.paneKey, card]))
const childrenByParent = new Map<string, DashboardCard[]>()
const childPaneKeys = new Set<string>()
for (const card of sorted) {
const parentPaneKey = card.parentPaneKey
if (!parentPaneKey || parentPaneKey === card.paneKey || !cardsByPaneKey.has(parentPaneKey)) {
continue
}
childPaneKeys.add(card.paneKey)
childrenByParent.set(parentPaneKey, [...(childrenByParent.get(parentPaneKey) ?? []), card])
}
if (childPaneKeys.size === 0) {
return null
}
const emitted = new Set<string>()
const roots = sorted.filter((card) => !childPaneKeys.has(card.paneKey))
const families: FleetAgentFamily[] = []
for (const root of roots) {
if (!emitted.has(root.paneKey)) {
families.push(buildFamily(root, childrenByParent, nodeRadius, emitted))
}
}
for (const card of sorted) {
if (!emitted.has(card.paneKey)) {
families.push(buildFamily(card, childrenByParent, nodeRadius, emitted))
}
}
const packed = packFleetWorktrees(families)
return {
agents: packed
.flatMap((family) =>
family.agents.map((agent) => ({
...agent,
x: family.x + agent.x,
y: family.y + agent.y
}))
)
.sort((a, b) => compareStable(a.card.paneKey, b.card.paneKey)),
radius: Math.max(
62,
...packed.map((family) => Math.hypot(family.x, family.y) + family.radius + WORKTREE_PADDING)
)
}
}
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import {
countFleetFocusCards,
filterFleetFocusCards,
finishedCardMatchesScope
} from './fleet-rings-focus-filter'
const NOW = 2_000_000_000
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
return {
paneKey: 'pane-1',
ptyId: 'pty-1',
agentType: 'codex',
bucket: 'done',
dotState: 'done',
task: '',
repoId: 'repo-1',
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
repoName: 'Orca',
worktreeName: 'Fleet rings',
startedAt: NOW - 60_000,
finishedAt: NOW - 30_000,
stateChangedAt: NOW - 30_000,
unseen: false,
hostKind: 'local',
...overrides
}
}
describe('fleet rings focus filtering', () => {
it('uses explicit review membership independently from seen state', () => {
const result = card()
expect(finishedCardMatchesScope(result, 'review', NOW, new Set())).toBe(true)
expect(finishedCardMatchesScope(result, 'review', NOW, new Set([result.paneKey]))).toBe(false)
})
it('bounds history scopes by the finished timestamp', () => {
const recent = card({ paneKey: 'recent', finishedAt: NOW - 23 * 60 * 60_000 })
const old = card({ paneKey: 'old', finishedAt: NOW - 8 * 24 * 60 * 60_000 })
expect(finishedCardMatchesScope(recent, 'day', NOW, new Set())).toBe(true)
expect(finishedCardMatchesScope(old, 'week', NOW, new Set())).toBe(false)
expect(finishedCardMatchesScope(old, 'all', NOW, new Set())).toBe(true)
})
it('lets pinned results bypass local Focus filters', () => {
const pinned = card({ paneKey: 'pinned', repoId: 'hidden', hostKind: 'ssh' })
const visible = filterFleetFocusCards({
cards: [pinned],
enabledStates: new Set(),
finishedScope: 'review',
hostFilter: 'local',
hiddenProjectIds: new Set(['hidden']),
pinnedPaneKeys: new Set(['pinned']),
reviewedPaneKeys: new Set(['pinned']),
now: NOW
})
expect(visible).toEqual([pinned])
})
it('reports state and finished-window counts from the same card set', () => {
const cards = [
card({ paneKey: 'done-new', unseen: true }),
card({ paneKey: 'done-reviewed' }),
card({ paneKey: 'working', bucket: 'working', dotState: 'working', finishedAt: null }),
card({ paneKey: 'waiting', bucket: 'attention', dotState: 'waiting', finishedAt: null })
]
expect(countFleetFocusCards(cards, NOW, new Set(['done-reviewed']))).toMatchObject({
attention: 1,
working: 1,
finished: 2,
review: 1,
day: 2,
week: 2,
all: 2
})
})
})
@@ -0,0 +1,116 @@
import type { DashboardCard, DashboardCardHostKind } from '../../../../shared/dashboard-snapshot'
export type FleetFocusState = 'attention' | 'working' | 'finished'
export type FleetFinishedScope = 'review' | 'day' | 'week' | 'all'
export type FleetHostFilter = 'all' | DashboardCardHostKind
export type FleetFocusCounts = Record<FleetFocusState, number> & Record<FleetFinishedScope, number>
const DAY_MS = 24 * 60 * 60 * 1000
export function fleetFocusState(card: DashboardCard): FleetFocusState | null {
if (card.dotState === 'blocked' || card.dotState === 'waiting') {
return 'attention'
}
if (card.dotState === 'working') {
return 'working'
}
if (card.dotState === 'done') {
return 'finished'
}
return null
}
function finishedTimestamp(card: DashboardCard): number {
return card.finishedAt ?? card.stateChangedAt
}
export function finishedCardMatchesScope(
card: DashboardCard,
scope: FleetFinishedScope,
now: number,
reviewedPaneKeys: ReadonlySet<string>
): boolean {
if (scope === 'review') {
return !reviewedPaneKeys.has(card.paneKey)
}
if (scope === 'all') {
return true
}
const timestamp = finishedTimestamp(card)
if (timestamp <= 0) {
return false
}
return timestamp >= now - (scope === 'day' ? DAY_MS : DAY_MS * 7)
}
export function filterFleetFocusCards({
cards,
enabledStates,
finishedScope,
hostFilter,
hiddenProjectIds,
pinnedPaneKeys,
reviewedPaneKeys,
now
}: {
cards: DashboardCard[]
enabledStates: ReadonlySet<FleetFocusState>
finishedScope: FleetFinishedScope
hostFilter: FleetHostFilter
hiddenProjectIds: ReadonlySet<string>
pinnedPaneKeys: ReadonlySet<string>
reviewedPaneKeys: ReadonlySet<string>
now: number
}): DashboardCard[] {
return cards.filter((card) => {
if (pinnedPaneKeys.has(card.paneKey)) {
return true
}
if (
(hostFilter !== 'all' && (card.hostKind ?? 'local') !== hostFilter) ||
hiddenProjectIds.has(card.repoId)
) {
return false
}
const state = fleetFocusState(card)
if (!state || !enabledStates.has(state)) {
return false
}
return (
state !== 'finished' || finishedCardMatchesScope(card, finishedScope, now, reviewedPaneKeys)
)
})
}
export function countFleetFocusCards(
cards: DashboardCard[],
now: number,
reviewedPaneKeys: ReadonlySet<string>
): FleetFocusCounts {
const counts: FleetFocusCounts = {
attention: 0,
working: 0,
finished: 0,
review: 0,
day: 0,
week: 0,
all: 0
}
for (const card of cards) {
const state = fleetFocusState(card)
if (state) {
counts[state] += 1
}
if (state !== 'finished') {
continue
}
counts.all += 1
for (const scope of ['review', 'day', 'week'] as const) {
if (finishedCardMatchesScope(card, scope, now, reviewedPaneKeys)) {
counts[scope] += 1
}
}
}
return counts
}
@@ -0,0 +1,269 @@
import { describe, expect, it } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import {
deriveFleetRingsLayout,
FLEET_AGENT_RADIUS,
FLEET_WORKTREE_GAP,
fleetAgentDurationMinutes,
fleetNodeStatus,
updateFleetRingsLayout
} from './fleet-rings-layout'
const NOW = 2_000_000_000
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
return {
paneKey: 'pane-1',
ptyId: 'pty-1',
agentType: 'codex',
bucket: 'working',
dotState: 'working',
task: 'Build rings',
repoId: 'repo-1',
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
repoName: 'Orca',
worktreeName: 'Fleet rings',
startedAt: NOW - 10 * 60_000,
finishedAt: null,
stateChangedAt: NOW - 1_000,
unseen: false,
...overrides
}
}
describe('fleet rings layout', () => {
it('derives project containment, workspace containment, and every agent node', () => {
const cards = [
card({ paneKey: 'a', repoId: 'repo-a', worktreeId: 'wt-a' }),
card({ paneKey: 'b', repoId: 'repo-a', worktreeId: 'wt-a' }),
card({ paneKey: 'c', repoId: 'repo-a', worktreeId: 'wt-b' }),
card({
paneKey: 'd',
repoId: 'repo-b',
repoName: 'Mobile',
worktreeId: 'wt-c'
})
]
const layout = deriveFleetRingsLayout(cards, NOW)
expect(layout.projects.map((project) => project.id)).toEqual(['repo-a', 'repo-b'])
expect(layout.projects[0].worktrees.map((worktree) => worktree.id)).toEqual(['wt-a', 'wt-b'])
expect(
layout.projects.flatMap((project) =>
project.worktrees.flatMap((worktree) => worktree.agents.map((agent) => agent.card.paneKey))
)
).toEqual(['a', 'b', 'c', 'd'])
for (const project of layout.projects) {
for (const worktree of project.worktrees) {
expect(
Math.hypot(worktree.x - project.x, worktree.y - project.y) + worktree.radius
).toBeLessThan(project.radius)
for (const agent of worktree.agents) {
expect(
Math.hypot(agent.x - worktree.x, agent.y - worktree.y) + agent.radius
).toBeLessThan(worktree.radius)
}
}
}
})
it('places orchestrated descendants beneath their direct parent inside the workspace', () => {
const layout = deriveFleetRingsLayout(
[
card({ paneKey: 'parent' }),
card({ paneKey: 'child-a', parentPaneKey: 'parent' }),
card({ paneKey: 'child-b', parentPaneKey: 'parent' }),
card({ paneKey: 'grandchild', parentPaneKey: 'child-a' })
],
NOW
)
const worktree = layout.projects[0].worktrees[0]
const agents = new Map(worktree.agents.map((agent) => [agent.card.paneKey, agent]))
const parent = agents.get('parent')!
const childA = agents.get('child-a')!
const childB = agents.get('child-b')!
const grandchild = agents.get('grandchild')!
expect(childA.y).toBeGreaterThan(parent.y)
expect(childB.y).toBeGreaterThan(parent.y)
expect(grandchild.y).toBeGreaterThan(childA.y)
for (const agent of worktree.agents) {
expect(Math.hypot(agent.x - worktree.x, agent.y - worktree.y) + agent.radius).toBeLessThan(
worktree.radius
)
}
})
it('repacks cached geometry when an orchestration parent changes', () => {
const cards = [
card({ paneKey: 'parent-a' }),
card({ paneKey: 'parent-b' }),
card({ paneKey: 'child', parentPaneKey: 'parent-a' })
]
const initial = updateFleetRingsLayout(null, cards, NOW)
const updated = updateFleetRingsLayout(
initial.cache,
cards.map((candidate) =>
candidate.paneKey === 'child' ? { ...candidate, parentPaneKey: 'parent-b' } : candidate
),
NOW
)
expect(updated.cache).not.toBe(initial.cache)
expect(updated.cache.packingGeneration).toBe(2)
expect(updated.layout.topologyKey).not.toBe(initial.layout.topologyKey)
})
it('keeps positions stable across routine status and duration updates', () => {
const initialCards = [
card({ paneKey: 'a', worktreeId: 'wt-a' }),
card({ paneKey: 'b', worktreeId: 'wt-a', startedAt: NOW - 2 * 60_000 }),
card({ paneKey: 'c', worktreeId: 'wt-b' })
]
const initial = deriveFleetRingsLayout(initialCards, NOW)
const updated = deriveFleetRingsLayout(
[
{ ...initialCards[0], bucket: 'attention', dotState: 'waiting' },
{ ...initialCards[1], startedAt: NOW - 45 * 60_000 },
initialCards[2]
],
NOW
)
const initialAgents = initial.projects[0].worktrees[0].agents
const updatedAgents = updated.projects[0].worktrees[0].agents
const initialWorktrees = initial.projects[0].worktrees
const updatedWorktrees = updated.projects[0].worktrees
expect(updated.topologyKey).toBe(initial.topologyKey)
expect(updatedWorktrees.map(({ x, y }) => ({ x, y }))).toEqual(
initialWorktrees.map(({ x, y }) => ({ x, y }))
)
expect(updatedAgents.map(({ x, y }) => ({ x, y }))).toEqual(
initialAgents.map(({ x, y }) => ({ x, y }))
)
expect(updatedAgents[1].radius).toBe(initialAgents[1].radius)
})
it('reuses packed geometry while refreshing live card metadata', () => {
const initialCards = [
card({ paneKey: 'a', worktreeId: 'wt-a' }),
card({ paneKey: 'b', worktreeId: 'wt-b' })
]
const initial = updateFleetRingsLayout(null, initialCards, NOW)
const updatedCards = [
{ ...initialCards[0], dotState: 'waiting' as const, worktreeName: 'Renamed' },
{ ...initialCards[1], startedAt: NOW - 60 * 60_000 }
]
const updated = updateFleetRingsLayout(initial.cache, updatedCards, NOW + 60_000)
expect(updated.cache).toBe(initial.cache)
expect(updated.cache.packingGeneration).toBe(1)
expect(updated.layout.projects[0].worktrees[0].name).toBe('Renamed')
expect(updated.layout.projects[0].worktrees[0].statusCounts.waiting).toBe(1)
expect(updated.layout.projects[0].worktrees[1].agents[0].durationMinutes).toBe(61)
const topologyChanged = updateFleetRingsLayout(
updated.cache,
[...updatedCards, card({ paneKey: 'c', worktreeId: 'wt-c' })],
NOW
)
expect(topologyChanged.cache).not.toBe(updated.cache)
expect(topologyChanged.cache.packingGeneration).toBe(2)
})
it('packs worktree rings tightly without a square grid', () => {
const layout = deriveFleetRingsLayout(
Array.from({ length: 36 }, (_, index) =>
card({
paneKey: `agent-${index}`,
worktreeId: `worktree-${index.toString().padStart(2, '0')}`
})
),
NOW
)
const project = layout.projects[0]
expect(project.radius).toBeLessThan(700)
expect(
new Set(project.worktrees.map((worktree) => worktree.x.toFixed(3))).size
).toBeGreaterThan(12)
expect(
new Set(project.worktrees.map((worktree) => worktree.y.toFixed(3))).size
).toBeGreaterThan(12)
for (const [index, worktree] of project.worktrees.entries()) {
for (const other of project.worktrees.slice(index + 1)) {
expect(Math.hypot(worktree.x - other.x, worktree.y - other.y)).toBeGreaterThanOrEqual(
worktree.radius + other.radius + FLEET_WORKTREE_GAP - 0.001
)
}
}
})
it('uses one agent size while retaining elapsed duration', () => {
const finished = card({
startedAt: NOW - 30 * 60_000,
finishedAt: NOW - 20 * 60_000
})
const layout = deriveFleetRingsLayout(
[
card({ paneKey: 'just-started', startedAt: NOW }),
card({ paneKey: 'long-running', startedAt: NOW - 24 * 60 * 60_000 })
],
NOW
)
expect(layout.projects[0].worktrees[0].agents.map((agent) => agent.radius)).toEqual([
FLEET_AGENT_RADIUS,
FLEET_AGENT_RADIUS
])
expect(fleetAgentDurationMinutes(finished, NOW)).toBe(10)
})
it('maps the precise Orca status independently from elapsed time', () => {
for (const dotState of ['working', 'blocked', 'waiting', 'done', 'idle'] as const) {
expect(fleetNodeStatus(card({ dotState }))).toBe(dotState)
}
const shortBlocked = card({ dotState: 'blocked', startedAt: NOW - 60_000 })
const longBlocked = card({ dotState: 'blocked', startedAt: NOW - 45 * 60_000 })
expect(fleetNodeStatus(shortBlocked)).toBe(fleetNodeStatus(longBlocked))
})
it('marks only operationally quiet workspaces for semantic aggregation', () => {
const quiet = deriveFleetRingsLayout(
Array.from({ length: 5 }, (_, index) =>
card({ paneKey: `quiet-${index}`, dotState: index === 0 ? 'done' : 'idle' })
),
NOW
)
const active = deriveFleetRingsLayout([card({ paneKey: 'active', dotState: 'working' })], NOW)
expect(quiet.projects[0].worktrees[0].quiet).toBe(true)
expect(active.projects[0].worktrees[0].quiet).toBe(false)
})
it('places hundreds of agents in one workspace without overlap', () => {
const layout = deriveFleetRingsLayout(
Array.from({ length: 400 }, (_, index) => card({ paneKey: `agent-${index}` })),
NOW
)
const worktree = layout.projects[0].worktrees[0]
expect(worktree.agents).toHaveLength(400)
let minimumDistance = Number.POSITIVE_INFINITY
for (const [index, agent] of worktree.agents.entries()) {
expect(Math.hypot(agent.x - worktree.x, agent.y - worktree.y) + agent.radius).toBeLessThan(
worktree.radius
)
for (const other of worktree.agents.slice(index + 1)) {
minimumDistance = Math.min(
minimumDistance,
Math.hypot(agent.x - other.x, agent.y - other.y)
)
}
}
expect(minimumDistance).toBeGreaterThanOrEqual(FLEET_AGENT_RADIUS * 2)
})
})
@@ -0,0 +1,314 @@
import type { DashboardCard, DashboardCardDotState } from '../../../../shared/dashboard-snapshot'
import { layoutFleetAgentLineage } from './fleet-agent-lineage-layout'
import { packFleetWorktrees } from './fleet-worktree-packing'
export { FLEET_WORKTREE_GAP } from './fleet-worktree-packing'
export const FLEET_AGENT_RADIUS = 20
export const FLEET_AGGREGATE_ZOOM = 1.15
const PROJECT_GAP = 32
const PROJECT_PADDING = 16
const WORLD_MARGIN = 32
const GOLDEN_ANGLE = 2.399963229728653
export type FleetStatusCounts = Record<DashboardCardDotState, number>
export type FleetAgentNode = {
card: DashboardCard
x: number
y: number
radius: number
durationMinutes: number
status: DashboardCardDotState
}
export type FleetWorktreeRing = {
id: string
name: string
x: number
y: number
radius: number
agents: FleetAgentNode[]
statusCounts: FleetStatusCounts
quiet: boolean
}
export type FleetProjectRing = {
id: string
name: string
x: number
y: number
radius: number
worktrees: FleetWorktreeRing[]
agentCount: number
}
export type FleetRingsLayout = {
projects: FleetProjectRing[]
width: number
height: number
topologyKey: string
}
export type FleetRingsLayoutCache = {
topologyKey: string
geometry: FleetRingsLayout
packingGeneration: number
}
type LocalWorktree = Omit<FleetWorktreeRing, 'x' | 'y'> & { x: number; y: number }
type LocalProject = Omit<FleetProjectRing, 'x' | 'y' | 'worktrees'> & {
worktrees: LocalWorktree[]
}
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
}
function hashFraction(value: string): number {
return stableHash(value) / 0xffffffff
}
function topologyIdentity(card: DashboardCard): string {
const parentPaneKey = card.parentPaneKey ?? ''
return `${card.repoId.length}:${card.repoId}${card.worktreeId.length}:${card.worktreeId}${card.paneKey.length}:${card.paneKey}${parentPaneKey.length}:${parentPaneKey}`
}
export function fleetRingsTopologyKey(cards: DashboardCard[]): string {
return cards.map(topologyIdentity).sort(compareStable).join('|')
}
export function fleetAgentDurationMinutes(card: DashboardCard, now: number): number {
if (!Number.isFinite(card.startedAt) || card.startedAt <= 0) {
return 0
}
const end = card.finishedAt && card.finishedAt >= card.startedAt ? card.finishedAt : now
return Math.max(0, (end - card.startedAt) / 60_000)
}
export function fleetNodeStatus(card: DashboardCard): DashboardCardDotState {
return card.dotState
}
export function shouldAggregateFleetWorktree(
worktree: FleetWorktreeRing,
zoom: number,
allowAggregation = true
): boolean {
return (
allowAggregation && zoom < FLEET_AGGREGATE_ZOOM && worktree.quiet && worktree.agents.length > 3
)
}
function emptyStatusCounts(): FleetStatusCounts {
return { working: 0, blocked: 0, waiting: 0, done: 0, idle: 0 }
}
function worktreeRadius(agentCount: number): number {
return Math.max(62, 34 + Math.ceil(Math.sqrt(Math.max(1, agentCount))) * (FLEET_AGENT_RADIUS + 8))
}
function placeAgents(
worktreeId: string,
cards: DashboardCard[],
radius: number,
now: number
): FleetAgentNode[] {
const availableRadius = Math.max(0, radius - FLEET_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 = hashFraction(worktreeId) * 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: FLEET_AGENT_RADIUS,
durationMinutes: fleetAgentDurationMinutes(card, now),
status: fleetNodeStatus(card)
}
})
}
function buildLocalWorktree(id: string, cards: DashboardCard[], now: number): LocalWorktree {
const lineageLayout = layoutFleetAgentLineage(cards, FLEET_AGENT_RADIUS)
const radius = lineageLayout?.radius ?? worktreeRadius(cards.length)
const statusCounts = emptyStatusCounts()
for (const card of cards) {
statusCounts[card.dotState] += 1
}
return {
id,
name: cards[0]?.worktreeName ?? id,
x: 0,
y: 0,
radius,
agents:
lineageLayout?.agents.map(({ card, x, y }) => ({
card,
x,
y,
radius: FLEET_AGENT_RADIUS,
durationMinutes: fleetAgentDurationMinutes(card, now),
status: fleetNodeStatus(card)
})) ?? placeAgents(id, cards, radius, now),
statusCounts,
quiet: statusCounts.working === 0 && statusCounts.blocked === 0 && statusCounts.waiting === 0
}
}
function buildLocalProject(id: string, cards: DashboardCard[], now: number): LocalProject {
const byWorktree = new Map<string, DashboardCard[]>()
for (const card of cards) {
const current = byWorktree.get(card.worktreeId)
if (current) {
current.push(card)
} else {
byWorktree.set(card.worktreeId, [card])
}
}
const worktrees = packFleetWorktrees(
[...byWorktree.entries()]
.sort(([a], [b]) => compareStable(a, b))
.map(([worktreeId, worktreeCards]) => buildLocalWorktree(worktreeId, worktreeCards, now))
)
const radius = Math.max(
96,
...worktrees.map(
(worktree) => Math.hypot(worktree.x, worktree.y) + worktree.radius + PROJECT_PADDING
)
)
return {
id,
name: cards[0]?.repoName ?? id,
radius,
worktrees,
agentCount: cards.length
}
}
export function deriveFleetRingsLayout(cards: DashboardCard[], now: number): FleetRingsLayout {
const topologyKey = fleetRingsTopologyKey(cards)
if (cards.length === 0) {
return { projects: [], width: 900, height: 560, topologyKey }
}
const byProject = new Map<string, DashboardCard[]>()
for (const card of cards) {
const current = byProject.get(card.repoId)
if (current) {
current.push(card)
} else {
byProject.set(card.repoId, [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 centerY = WORLD_MARGIN + maxProjectRadius
let cursorX = WORLD_MARGIN
const projects = localProjects.map((project): FleetProjectRing => {
const centerX = cursorX + project.radius
cursorX += project.radius * 2 + PROJECT_GAP
return {
...project,
x: centerX,
y: centerY,
worktrees: project.worktrees.map((worktree) => ({
...worktree,
x: centerX + worktree.x,
y: centerY + worktree.y,
agents: worktree.agents.map((agent) => ({
...agent,
x: centerX + worktree.x + agent.x,
y: centerY + worktree.y + agent.y
}))
}))
}
})
return {
projects,
width: Math.max(900, cursorX - PROJECT_GAP + WORLD_MARGIN),
height: Math.max(560, maxProjectRadius * 2 + WORLD_MARGIN * 2),
topologyKey
}
}
function refreshFleetRingsMetadata(
geometry: FleetRingsLayout,
cards: DashboardCard[],
now: number
): FleetRingsLayout {
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
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
agentCount += 1
statusCounts[card.dotState] += 1
return [
{
...agent,
card,
durationMinutes: fleetAgentDurationMinutes(card, now),
status: fleetNodeStatus(card)
}
]
})
return {
...worktree,
name: worktreeName,
agents,
statusCounts,
quiet:
statusCounts.working === 0 && statusCounts.blocked === 0 && statusCounts.waiting === 0
}
})
return { ...project, name: projectName, worktrees, agentCount }
})
return { ...geometry, projects }
}
export function updateFleetRingsLayout(
cache: FleetRingsLayoutCache | null,
cards: DashboardCard[],
now: number
): { cache: FleetRingsLayoutCache; layout: FleetRingsLayout } {
const topologyKey = fleetRingsTopologyKey(cards)
if (!cache || cache.topologyKey !== topologyKey) {
const geometry = deriveFleetRingsLayout(cards, now)
return {
cache: {
topologyKey,
geometry,
packingGeneration: (cache?.packingGeneration ?? 0) + 1
},
layout: geometry
}
}
const layout = refreshFleetRingsMetadata(cache.geometry, cards, now)
cache.geometry = layout
return { cache, layout }
}
@@ -0,0 +1,248 @@
.fleet-rings-canvas {
background-image: radial-gradient(
color-mix(in srgb, var(--muted-foreground) 16%, transparent) 0.6px,
transparent 0.6px
);
background-size: 20px 20px;
}
.fleet-rings-canvas::before {
position: absolute;
inset: 0;
background: radial-gradient(
circle at 45% 48%,
color-mix(in srgb, var(--muted) 34%, transparent),
transparent 70%
);
content: '';
pointer-events: none;
}
.fleet-project-ring {
fill: color-mix(in srgb, var(--card) 22%, transparent);
stroke: color-mix(in srgb, var(--ring) 58%, transparent);
stroke-width: 1.25;
vector-effect: non-scaling-stroke;
}
.fleet-project-label,
.fleet-worktree-label {
fill: var(--foreground);
font-family: Geist, var(--font-sans);
font-weight: 600;
letter-spacing: 0.05em;
paint-order: stroke fill;
pointer-events: none;
stroke: var(--background);
stroke-linejoin: round;
stroke-width: 2px;
}
.fleet-project-label {
font-size: 13px;
text-anchor: middle;
}
.fleet-project-count,
.fleet-worktree-count {
fill: var(--muted-foreground);
font-family: Geist, var(--font-sans);
letter-spacing: 0.05em;
paint-order: stroke fill;
pointer-events: none;
stroke: var(--background);
stroke-linejoin: round;
stroke-width: 2px;
}
.fleet-project-count {
font-size: 11px;
text-anchor: middle;
}
.fleet-worktree-ring {
fill: color-mix(in srgb, var(--card) 48%, transparent);
stroke: color-mix(in srgb, var(--muted-foreground) 42%, transparent);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.fleet-worktree-ring:hover {
fill: color-mix(in srgb, var(--card) 72%, transparent);
stroke: var(--ring);
}
.fleet-worktree-ring.is-selected {
fill: color-mix(in srgb, var(--ring) 6%, var(--card));
stroke: var(--ring);
stroke-width: 1.5;
}
.fleet-worktree-label {
font-size: 12px;
font-weight: 500;
letter-spacing: 0.01em;
text-anchor: middle;
}
.fleet-worktree-count {
font-size: 11px;
text-anchor: middle;
}
.fleet-worktree-label-group {
opacity: 0;
pointer-events: none;
}
.fleet-worktree-label-group.is-visible,
.fleet-worktree-group:hover .fleet-worktree-label-group,
.fleet-worktree-group:focus-within .fleet-worktree-label-group {
opacity: 1;
}
.fleet-worktree-count {
opacity: 0;
}
.fleet-worktree-label-group.is-count-visible .fleet-worktree-count,
.fleet-worktree-group:hover .fleet-worktree-count,
.fleet-worktree-group:focus-within .fleet-worktree-count {
opacity: 1;
}
.fleet-agent-node {
cursor: pointer;
outline: none;
}
.fleet-agent-lineage-link {
fill: none;
pointer-events: none;
stroke: color-mix(in srgb, var(--muted-foreground) 38%, transparent);
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.fleet-agent-hit {
fill: transparent;
stroke: transparent;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
.fleet-agent-mark {
fill: var(--background);
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
.fleet-agent-icon,
.fleet-agent-state {
overflow: visible;
pointer-events: none;
}
.fleet-agent-icon > div {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
color: var(--foreground);
}
.fleet-agent-state > div {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: var(--background);
}
.fleet-agent-node:hover .fleet-agent-hit,
.fleet-agent-node:focus-visible .fleet-agent-hit,
.fleet-agent-node.is-selected .fleet-agent-hit {
fill: color-mix(in srgb, var(--ring) 10%, transparent);
stroke: var(--ring);
}
.fleet-agent-node.is-selected .fleet-agent-hit {
stroke-width: 2;
}
.fleet-status-working .fleet-agent-mark {
stroke: var(--color-yellow-500);
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--color-yellow-500) 72%, transparent));
}
.fleet-status-blocked .fleet-agent-mark {
stroke: var(--color-red-500);
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--color-red-500) 72%, transparent));
}
.fleet-status-waiting .fleet-agent-mark {
stroke: var(--color-amber-500);
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--color-amber-500) 72%, transparent));
}
.fleet-status-done .fleet-agent-mark {
stroke: var(--color-emerald-500);
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--color-emerald-500) 72%, transparent));
}
.fleet-status-idle .fleet-agent-mark {
stroke: color-mix(in srgb, var(--color-neutral-500) 55%, transparent);
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--color-neutral-500) 28%, transparent));
}
.fleet-aggregate-node circle {
fill: color-mix(in srgb, var(--muted-foreground) 12%, var(--card));
stroke: color-mix(in srgb, var(--muted-foreground) 46%, transparent);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.fleet-aggregate-node text {
fill: var(--muted-foreground);
font-family: Geist, var(--font-sans);
font-size: 11px;
font-weight: 600;
text-anchor: middle;
}
.fleet-legend-dot {
display: block;
width: 7px;
height: 7px;
border-radius: 9999px;
background: var(--muted-foreground);
opacity: 0.42;
}
.fleet-legend-dot.fleet-status-working {
border: 2px solid var(--color-yellow-500);
border-top-color: transparent;
background: transparent;
opacity: 1;
}
.fleet-legend-dot.fleet-status-blocked {
background: var(--color-red-500);
opacity: 1;
}
.fleet-legend-dot.fleet-status-waiting {
background: var(--color-amber-500);
box-shadow: 0 0 4px color-mix(in srgb, var(--color-amber-500) 72%, transparent);
opacity: 1;
}
.fleet-legend-dot.fleet-status-done {
background: var(--color-emerald-500);
opacity: 1;
}
@@ -0,0 +1,163 @@
export const FLEET_WORKTREE_GAP = 10
const PACKING_ANGLE_STEPS = 72
const SCORE_TOLERANCE = 0.001
const CENTER_DIRECTIONS = [
[-1, -1],
[0, -1],
[1, -1],
[-1, 0],
[1, 0],
[-1, 1],
[0, 1],
[1, 1]
] as const
type PackableWorktree = {
id: string
x: number
y: number
radius: number
}
type PackingCandidate = {
x: number
y: number
enclosingRadius: number
distanceFromCenter: number
neighborDistance: number
}
function compareStable(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0
}
function hashFraction(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) / 0xffffffff
}
function worktreesOverlap(
candidate: Pick<PackableWorktree, 'x' | 'y' | 'radius'>,
placed: PackableWorktree[]
): boolean {
return placed.some(
(worktree) =>
Math.hypot(candidate.x - worktree.x, candidate.y - worktree.y) <
candidate.radius + worktree.radius + FLEET_WORKTREE_GAP - SCORE_TOLERANCE
)
}
function comparePackingScores(a: PackingCandidate, b: PackingCandidate): number {
const aScore = [a.enclosingRadius, a.distanceFromCenter, a.neighborDistance]
const bScore = [b.enclosingRadius, b.distanceFromCenter, b.neighborDistance]
for (const [index, score] of aScore.entries()) {
if (Math.abs(score - bScore[index]) > SCORE_TOLERANCE) {
return score - bScore[index]
}
}
return 0
}
function placePackedWorktree(worktree: PackableWorktree, placed: PackableWorktree[]): void {
const currentRadius = Math.max(
0,
...placed.map((candidate) => Math.hypot(candidate.x, candidate.y) + candidate.radius)
)
let best: PackingCandidate | undefined
for (const anchor of placed) {
const orbit = anchor.radius + worktree.radius + FLEET_WORKTREE_GAP
const angleOffset = hashFraction(`${worktree.id}:${anchor.id}`) * Math.PI * 2
for (let step = 0; step < PACKING_ANGLE_STEPS; step += 1) {
const angle = angleOffset + (step / PACKING_ANGLE_STEPS) * Math.PI * 2
const x = anchor.x + Math.cos(angle) * orbit
const y = anchor.y + Math.sin(angle) * orbit
if (worktreesOverlap({ x, y, radius: worktree.radius }, placed)) {
continue
}
const distanceFromCenter = Math.hypot(x, y)
const candidate = {
x,
y,
enclosingRadius: Math.max(currentRadius, distanceFromCenter + worktree.radius),
distanceFromCenter,
neighborDistance: placed.reduce(
(sum, other) => sum + Math.hypot(x - other.x, y - other.y),
0
)
}
if (!best || comparePackingScores(candidate, best) < 0) {
best = candidate
}
}
}
worktree.x =
best?.x ??
Math.max(...placed.map((candidate) => candidate.x + candidate.radius)) +
worktree.radius +
FLEET_WORKTREE_GAP
worktree.y = best?.y ?? 0
}
function enclosingRadius(worktrees: PackableWorktree[], x: number, y: number): number {
return Math.max(
...worktrees.map((worktree) => Math.hypot(worktree.x - x, worktree.y - y) + worktree.radius)
)
}
function findEnclosingCenter(
worktrees: PackableWorktree[],
bounds: { left: number; right: number; top: number; bottom: number }
): { x: number; y: number } {
let x = (bounds.left + bounds.right) / 2
let y = (bounds.top + bounds.bottom) / 2
let radius = enclosingRadius(worktrees, x, y)
let step = Math.max(bounds.right - bounds.left, bounds.bottom - bounds.top) / 4
while (step > SCORE_TOLERANCE) {
let improved = false
for (const [dx, dy] of CENTER_DIRECTIONS) {
const candidateX = x + dx * step
const candidateY = y + dy * step
const candidateRadius = enclosingRadius(worktrees, candidateX, candidateY)
if (candidateRadius < radius - SCORE_TOLERANCE) {
x = candidateX
y = candidateY
radius = candidateRadius
improved = true
}
}
if (!improved) {
step /= 2
}
}
return { x, y }
}
export function packFleetWorktrees<T extends PackableWorktree>(worktrees: T[]): T[] {
const packed = [...worktrees].sort((a, b) => b.radius - a.radius || compareStable(a.id, b.id))
for (const [index, worktree] of packed.entries()) {
if (index > 0) {
placePackedWorktree(worktree, packed.slice(0, index))
}
}
if (packed.length === 0) {
return packed
}
const left = Math.min(...packed.map((worktree) => worktree.x - worktree.radius))
const right = Math.max(...packed.map((worktree) => worktree.x + worktree.radius))
const top = Math.min(...packed.map((worktree) => worktree.y - worktree.radius))
const bottom = Math.max(...packed.map((worktree) => worktree.y + worktree.radius))
const center = findEnclosingCenter(packed, { left, right, top, bottom })
for (const worktree of packed) {
worktree.x -= center.x
worktree.y -= center.y
}
return packed.sort((a, b) => compareStable(a.id, b.id))
}
@@ -0,0 +1,84 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { useFleetResultDisposition } from './use-fleet-result-disposition'
function card(id: string): DashboardCard {
return {
paneKey: id,
ptyId: null,
agentType: 'codex',
bucket: 'done',
dotState: 'done',
task: '',
repoId: 'repo',
worktreeId: 'worktree',
tabId: `tab-${id}`,
leafId: null,
repoName: 'Orca',
worktreeName: 'Worktree',
startedAt: 1,
finishedAt: 2,
stateChangedAt: 2,
unseen: true
}
}
describe('useFleetResultDisposition', () => {
afterEach(() => {
cleanup()
localStorage.clear()
})
it('caps in-memory and persisted pinned pane keys', () => {
const { result } = renderHook(() => useFleetResultDisposition([], vi.fn()))
act(() => {
for (let index = 0; index < 510; index += 1) {
result.current.togglePinned(card(`pane-${index}`))
}
})
expect(result.current.pinnedPaneKeys.size).toBe(500)
expect(result.current.pinnedPaneKeys.has('pane-0')).toBe(false)
expect(result.current.pinnedPaneKeys.has('pane-509')).toBe(true)
expect(
JSON.parse(localStorage.getItem('orca.dashboard.rings.pinned-pane-keys') ?? '[]')
).toHaveLength(500)
})
it('caps pane keys loaded from storage', () => {
localStorage.setItem(
'orca.dashboard.rings.pinned-pane-keys',
JSON.stringify(Array.from({ length: 510 }, (_, index) => `stored-${index}`))
)
const { result } = renderHook(() => useFleetResultDisposition([], vi.fn()))
expect(result.current.pinnedPaneKeys.size).toBe(500)
expect(result.current.pinnedPaneKeys.has('stored-9')).toBe(false)
expect(result.current.pinnedPaneKeys.has('stored-10')).toBe(true)
expect(result.current.pinnedPaneKeys.has('stored-509')).toBe(true)
})
it('drops reviewed identities that are absent from the current snapshot', () => {
const initialCards = Array.from({ length: 600 }, (_, index) => card(`old-${index}`))
const onAckAgent = vi.fn()
const { result, rerender } = renderHook(
({ cards }: { cards: DashboardCard[] }) => useFleetResultDisposition(cards, onAckAgent),
{ initialProps: { cards: initialCards } }
)
act(() => result.current.markReviewed(initialCards))
expect(result.current.reviewedPaneKeys.size).toBe(600)
const replacement = [card('replacement')]
rerender({ cards: replacement })
expect(result.current.reviewedPaneKeys.size).toBe(0)
act(() => result.current.markReviewed(replacement))
expect(result.current.reviewedPaneKeys).toEqual(new Set(['replacement']))
})
})
@@ -0,0 +1,170 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
const PENDING_REVIEW_STORAGE_KEY = 'orca.dashboard.rings.pending-review-pane-keys'
const PINNED_STORAGE_KEY = 'orca.dashboard.rings.pinned-pane-keys'
const MAX_STORED_PANE_KEYS = 500
function readStoredPaneKeys(key: string): Set<string> {
try {
const stored: unknown = JSON.parse(localStorage.getItem(key) ?? '[]')
if (!Array.isArray(stored)) {
return new Set()
}
const paneKeys: string[] = []
for (
let index = stored.length - 1;
index >= 0 && paneKeys.length < MAX_STORED_PANE_KEYS;
index--
) {
const value = stored[index]
if (typeof value === 'string') {
paneKeys.push(value)
}
}
paneKeys.reverse()
return new Set(paneKeys)
} catch {
return new Set()
}
}
function storePaneKeys(key: string, paneKeys: ReadonlySet<string>): void {
try {
localStorage.setItem(key, JSON.stringify([...paneKeys].slice(-MAX_STORED_PANE_KEYS)))
} catch {
// Dashboard state still works for the current mount when storage is unavailable.
}
}
function addStoredPaneKey(current: ReadonlySet<string>, paneKey: string): Set<string> {
if (current.has(paneKey)) {
return current as Set<string>
}
const next = new Set(current)
next.add(paneKey)
while (next.size > MAX_STORED_PANE_KEYS) {
next.delete(next.values().next().value!)
}
return next
}
function removePaneKeys(current: ReadonlySet<string>, paneKeys: ReadonlySet<string>): Set<string> {
let changed = false
for (const paneKey of paneKeys) {
if (current.has(paneKey)) {
changed = true
break
}
}
if (!changed) {
return current as Set<string>
}
const next = new Set<string>()
for (const paneKey of current) {
if (!paneKeys.has(paneKey)) {
next.add(paneKey)
}
}
return next
}
export function useFleetResultDisposition(
cards: DashboardCard[],
onAckAgent: (paneKey: string) => void
): {
reviewedPaneKeys: ReadonlySet<string>
pinnedPaneKeys: ReadonlySet<string>
acknowledge: (card: DashboardCard) => void
markReviewed: (cards: DashboardCard[]) => void
togglePinned: (card: DashboardCard) => void
} {
const [explicitlyReviewed, setExplicitlyReviewed] = useState<Set<string>>(() => new Set())
const [keptForReview, setKeptForReview] = useState<Set<string>>(() =>
readStoredPaneKeys(PENDING_REVIEW_STORAGE_KEY)
)
const [pinnedPaneKeys, setPinnedPaneKeys] = useState<Set<string>>(() =>
readStoredPaneKeys(PINNED_STORAGE_KEY)
)
const reviewedPaneKeys = useMemo(() => {
const reviewed = new Set<string>()
for (const card of cards) {
if (explicitlyReviewed.has(card.paneKey)) {
reviewed.add(card.paneKey)
}
if (card.dotState === 'done' && !card.unseen && !keptForReview.has(card.paneKey)) {
reviewed.add(card.paneKey)
}
}
return reviewed
}, [cards, explicitlyReviewed, keptForReview])
useEffect(() => {
storePaneKeys(PENDING_REVIEW_STORAGE_KEY, keptForReview)
}, [keptForReview])
useEffect(() => {
storePaneKeys(PINNED_STORAGE_KEY, pinnedPaneKeys)
}, [pinnedPaneKeys])
const acknowledge = useCallback(
(card: DashboardCard): void => {
if (card.dotState === 'done' && card.unseen) {
setKeptForReview((current) => addStoredPaneKey(current, card.paneKey))
}
onAckAgent(card.paneKey)
},
[onAckAgent]
)
const markReviewed = useCallback(
(reviewedCards: DashboardCard[]): void => {
const paneKeys = new Set<string>()
for (const card of reviewedCards) {
paneKeys.add(card.paneKey)
}
const activePaneKeys = new Set<string>()
for (const card of cards) {
activePaneKeys.add(card.paneKey)
}
setExplicitlyReviewed((current) => {
const next = new Set<string>()
for (const paneKey of current) {
if (activePaneKeys.has(paneKey)) {
next.add(paneKey)
}
}
for (const paneKey of paneKeys) {
if (activePaneKeys.has(paneKey)) {
next.add(paneKey)
}
}
return next
})
setKeptForReview((current) => removePaneKeys(current, paneKeys))
for (const card of reviewedCards) {
onAckAgent(card.paneKey)
}
},
[cards, onAckAgent]
)
const togglePinned = useCallback((card: DashboardCard): void => {
setPinnedPaneKeys((current) => {
if (!current.has(card.paneKey)) {
return addStoredPaneKey(current, card.paneKey)
}
const next = new Set(current)
next.delete(card.paneKey)
return next
})
}, [])
return {
reviewedPaneKeys,
pinnedPaneKeys,
acknowledge,
markReviewed,
togglePinned
}
}
@@ -64,6 +64,7 @@ function AgentDashboardDrawerBody({
return (
<AgentKanbanBoard
snapshot={snapshot}
initialView="board"
// Why: bg-transparent lets the sheet's worktree-sidebar surface through
// so the board reads as the same companion panel as the workspace board.
containerClassName="h-full w-full bg-transparent"
@@ -3,6 +3,7 @@ import { buildAgentRowLineageTree } from './agent-row-lineage-model'
export type AgentRowLineagePresentation = {
depth: 0 | 1
parentPaneKey?: string
isFirstSibling: boolean
isLastSibling: boolean
childCount: number
@@ -52,6 +53,7 @@ export function applyAgentRowLineage(rows: DashboardAgentRow[]): DashboardAgentR
// visible parent, but visual depth remains capped so dense sidebar
// rows don't lose too much prompt width.
depth: 1,
parentPaneKey: row.paneKey,
isFirstSibling: index === 0,
isLastSibling: index === children.length - 1,
childCount: 0
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import type { FolderWorkspace, ProjectGroup, TerminalTab } from '../../../../shared/types'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot'
const NOW = 2_000_000_000
const WORKSPACE_ID = folderWorkspaceKey('folder-1')
const TAB_ID = 'folder-tab'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
function folderWorkspace(): FolderWorkspace {
return {
id: 'folder-1',
projectGroupId: 'group-1',
name: 'Docs workspace',
folderPath: '/workspace/docs',
connectionId: 'ssh-1',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: NOW,
createdAt: NOW,
updatedAt: NOW
}
}
function projectGroup(): ProjectGroup {
return {
id: 'group-1',
name: 'Documentation',
parentPath: '/workspace',
connectionId: 'ssh-1',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: NOW,
updatedAt: NOW
}
}
function tab(): TerminalTab {
return {
id: TAB_ID,
ptyId: 'pty-folder',
worktreeId: WORKSPACE_ID,
title: 'codex',
customTitle: 'Docs reviewer',
color: null,
sortOrder: 0,
createdAt: NOW
}
}
function entry(): AgentStatusEntry {
return {
paneKey: PANE_KEY,
state: 'working',
prompt: 'Review the docs',
updatedAt: NOW,
stateStartedAt: NOW - 60_000,
stateHistory: [],
agentType: 'codex',
tabId: TAB_ID,
worktreeId: WORKSPACE_ID
}
}
function state(): DashboardSnapshotState {
return {
repos: [],
worktreesByRepo: {},
folderWorkspaces: [folderWorkspace()],
projectGroups: [projectGroup()],
tabsByWorktree: { [WORKSPACE_ID]: [tab()] },
agentStatusByPaneKey: { [PANE_KEY]: entry() },
retainedAgentsByPaneKey: {},
migrationUnsupportedByPtyId: {},
runtimeAgentOrchestrationByPaneKey: {},
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
ptyIdsByLeafId: { [LEAF_ID]: 'pty-folder' }
}
},
ptyIdsByTabId: { [TAB_ID]: ['pty-folder'] },
runtimePaneTitlesByTabId: {},
acknowledgedAgentsByPaneKey: {},
settings: null
} as unknown as DashboardSnapshotState
}
describe('buildDashboardSnapshot folder workspaces', () => {
it('places folder-workspace agents in their real project group without git assumptions', () => {
const snapshot = buildDashboardSnapshot(state(), NOW)
expect(snapshot.cards).toHaveLength(1)
expect(snapshot.cards[0]).toMatchObject({
paneKey: PANE_KEY,
repoId: 'folder-workspace:group-1',
repoName: 'Documentation',
worktreeId: WORKSPACE_ID,
worktreeName: 'Docs workspace',
workspaceKind: 'folder',
hostKind: 'ssh'
})
expect(snapshot.filterOptions?.projects).toEqual([
{ id: 'folder-workspace:group-1', label: 'Documentation' }
])
})
})
@@ -13,8 +13,12 @@ import { selectRuntimeAgentOrchestrationBatch } from '../sidebar/worktree-agent-
const NOW = 1_000_000_000
const TAB_ID = 'tab1'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const CHILD_LEAF_ID = '33333333-3333-4333-8333-333333333333'
const GRANDCHILD_LEAF_ID = '44444444-4444-4444-8444-444444444444'
const GONE_LEAF_ID = '22222222-2222-4222-8222-222222222222'
const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
const CHILD_PANE_KEY = makePaneKey(TAB_ID, CHILD_LEAF_ID)
const GRANDCHILD_PANE_KEY = makePaneKey(TAB_ID, GRANDCHILD_LEAF_ID)
function entry(overrides: Partial<AgentStatusEntry>): AgentStatusEntry {
return {
@@ -147,6 +151,60 @@ describe('buildDashboardSnapshot', () => {
expect(card.unseen).toBe(true)
})
it('publishes terminal-backed orchestrated workers under their direct parent', () => {
const snapshot = buildDashboardSnapshot(
baseState({
agentStatusByPaneKey: {
[PANE_KEY]: entry({ paneKey: PANE_KEY }),
[CHILD_PANE_KEY]: entry({ paneKey: CHILD_PANE_KEY }),
[GRANDCHILD_PANE_KEY]: entry({ paneKey: GRANDCHILD_PANE_KEY })
},
runtimeAgentOrchestrationByPaneKey: {
[CHILD_PANE_KEY]: {
taskId: 'worker-task',
dispatchId: 'worker-dispatch',
parentPaneKey: PANE_KEY
},
[GRANDCHILD_PANE_KEY]: {
taskId: 'nested-worker-task',
dispatchId: 'nested-worker-dispatch',
parentPaneKey: CHILD_PANE_KEY
}
},
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: {
[LEAF_ID]: 'pty1',
[CHILD_LEAF_ID]: 'pty-child',
[GRANDCHILD_LEAF_ID]: 'pty-grandchild'
}
}
},
ptyIdsByTabId: { [TAB_ID]: ['pty1', 'pty-child', 'pty-grandchild'] }
}),
NOW
)
expect(snapshot.cards.map((card) => card.paneKey)).toEqual([
PANE_KEY,
CHILD_PANE_KEY,
GRANDCHILD_PANE_KEY
])
expect(snapshot.cards[1]).toMatchObject({
paneKey: CHILD_PANE_KEY,
parentPaneKey: PANE_KEY,
ptyId: 'pty-child'
})
expect(snapshot.cards[2]).toMatchObject({
paneKey: GRANDCHILD_PANE_KEY,
parentPaneKey: CHILD_PANE_KEY,
ptyId: 'pty-grandchild'
})
})
it('carries the tab conversation name and drops status-only titles', () => {
const named = buildDashboardSnapshot(
baseState({
@@ -41,6 +41,10 @@ import {
resolveDashboardCardContext,
type DashboardCardContextState
} from './dashboard-card-context'
import {
collectActiveDashboardWorkspaces,
dashboardCardHostKind
} from './dashboard-snapshot-workspaces'
/** The store slices the snapshot builder reads. Kept as a Pick so unit tests
* can pass a partial store without constructing the whole AppState. */
@@ -120,8 +124,8 @@ function rowConversationName(
* Derive the serializable dashboard snapshot from the live renderer store.
* Reuses the exact per-worktree row machinery the sidebar uses
* (buildWorktreeAgentRows + the indexed selectors), then flattens every
* worktree's rows into presentational cards. Subagent/child rows are excluded
* from the board (out of scope for v1).
* worktree's rows into presentational cards. Provider subagents without their
* own terminal stay folded into their spawning card.
*/
export function buildDashboardSnapshot(
state: DashboardSnapshotState,
@@ -133,27 +137,21 @@ export function buildDashboardSnapshot(
const repoIconsByRepoId: Record<string, RepoIcon | null> = {}
const includeCardDetails = options.includeCardDetails !== false
const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true
const activeWorktrees: {
repo: AppState['repos'][number]
worktree: AppState['worktreesByRepo'][string][number]
}[] = []
for (const repo of state.repos ?? []) {
for (const worktree of state.worktreesByRepo?.[repo.id] ?? []) {
if (!worktree.isArchived) {
activeWorktrees.push({ repo, worktree })
}
}
}
const activeWorktrees = collectActiveDashboardWorkspaces(state)
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(({ repo }) => [repo.id, repo])).values()].map(
(repo) => ({ id: repo.id, label: boundedLabel(repo.displayName) })
),
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
@@ -181,7 +179,8 @@ export function buildDashboardSnapshot(
}
}
for (const { repo, worktree } of activeWorktrees) {
for (const workspace of activeWorktrees) {
const { repo, worktree } = workspace
const worktreeId = worktree.id
const liveEntries = selectLiveAgentStatusEntriesForWorktree(state, worktreeId)
const migrationUnsupported = selectMigrationUnsupportedEntriesForWorktree(state, worktreeId)
@@ -287,7 +286,7 @@ export function buildDashboardSnapshot(
})
: null
// Only repos that actually contribute a card ship their icon.
repoIconsByRepoId[repo.id] = repo.repoIcon ?? null
repoIconsByRepoId[workspace.projectId] = workspace.repoIcon
cards.push({
paneKey: row.paneKey,
@@ -296,12 +295,20 @@ export function buildDashboardSnapshot(
bucket,
dotState,
task: isTitleDerived ? '' : rowTask(row),
repoId: repo.id,
repoId: workspace.projectId,
worktreeId,
tabId,
leafId,
repoName: boundedLabel(repo.displayName),
parentPaneKey: row.lineage.parentPaneKey,
repoName: boundedLabel(workspace.projectName),
worktreeName: boundedLabel(worktree.displayName),
hostKind: dashboardCardHostKind(
workspace,
ptyId,
terminalInput ?? undefined,
clientHost.platform
),
workspaceKind: workspace.workspaceKind,
workspaceStatusId: context?.workspaceStatus.id,
workspaceStatusLabel: context?.workspaceStatus.label,
workspaceStatusColor: context?.workspaceStatus.color,
@@ -37,10 +37,10 @@ function hasLinkedReview(worktree: Worktree): boolean {
function resolveReview(
state: DashboardCardContextState,
repo: Repo,
repo: Repo | null,
worktree: Worktree
): DashboardCardReview | undefined {
if (!state.hostedReviewCache || !state.prCache || repo.kind === 'folder') {
if (!repo || !state.hostedReviewCache || !state.prCache || repo.kind === 'folder') {
return undefined
}
const branch = branchName(worktree.branch)
@@ -77,7 +77,7 @@ function resolveReview(
export function resolveDashboardCardContext(
state: DashboardCardContextState,
repo: Repo,
repo: Repo | null,
worktree: Worktree
): DashboardCardContext {
const statuses =
@@ -0,0 +1,101 @@
import type { AppState } from '@/store/types'
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
import type {
DashboardCard,
DashboardCardHostKind,
DashboardCardWorkspaceKind
} from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
export type ActiveDashboardWorkspace = {
projectId: string
projectName: string
repo: AppState['repos'][number] | null
repoIcon: RepoIcon | null
worktree: AppState['worktreesByRepo'][string][number]
workspaceKind: DashboardCardWorkspaceKind
remoteHostKind: Extract<DashboardCardHostKind, 'ssh' | 'remote'> | null
}
type DashboardWorkspaceState = Pick<AppState, 'repos' | 'worktreesByRepo'> &
Partial<Pick<AppState, 'folderWorkspaces' | 'projectGroups'>>
function remoteHostKind(
connectionId: string | null | undefined,
executionHostId: string | null | undefined
): ActiveDashboardWorkspace['remoteHostKind'] {
if (connectionId || executionHostId?.startsWith('ssh:')) {
return 'ssh'
}
return executionHostId && executionHostId !== 'local' ? 'remote' : null
}
export function collectActiveDashboardWorkspaces(
state: DashboardWorkspaceState
): ActiveDashboardWorkspace[] {
const workspaces: ActiveDashboardWorkspace[] = []
const seenWorkspaceIds = new Set<string>()
for (const repo of state.repos ?? []) {
for (const worktree of state.worktreesByRepo?.[repo.id] ?? []) {
if (worktree.isArchived) {
continue
}
seenWorkspaceIds.add(worktree.id)
workspaces.push({
projectId: repo.id,
projectName: repo.displayName,
repo,
repoIcon: repo.repoIcon ?? null,
worktree,
workspaceKind: isFolderRepo(repo) ? 'folder' : 'worktree',
remoteHostKind: remoteHostKind(repo.connectionId, worktree.hostId ?? repo.executionHostId)
})
}
}
const projectGroupsById = new Map(
(state.projectGroups ?? []).map((projectGroup) => [projectGroup.id, projectGroup])
)
for (const folderWorkspace of state.folderWorkspaces ?? []) {
const worktree = folderWorkspaceToWorktree(folderWorkspace)
if (folderWorkspace.isArchived || seenWorkspaceIds.has(worktree.id)) {
continue
}
const projectGroup = projectGroupsById.get(folderWorkspace.projectGroupId)
workspaces.push({
projectId: `folder-workspace:${folderWorkspace.projectGroupId}`,
projectName: projectGroup?.name ?? folderWorkspace.name,
repo: null,
repoIcon: null,
worktree,
workspaceKind: 'folder',
remoteHostKind: remoteHostKind(
folderWorkspace.connectionId ?? projectGroup?.connectionId,
projectGroup?.executionHostId
)
})
}
return workspaces
}
export function dashboardCardHostKind(
workspace: ActiveDashboardWorkspace,
ptyId: string | null,
terminalInput: DashboardCard['terminalInput'],
clientPlatform: NodeJS.Platform
): DashboardCardHostKind {
if (workspace.remoteHostKind) {
return workspace.remoteHostKind
}
if (ptyId && parseAppSshPtyId(ptyId)) {
return 'ssh'
}
if (ptyId && getRemoteRuntimePtyEnvironmentId(ptyId)) {
return 'remote'
}
return clientPlatform === 'win32' && terminalInput?.hostPlatform === 'linux' ? 'wsl' : 'local'
}
@@ -32,6 +32,7 @@ export type DashboardAgentRow = {
startedAt: number
lineage?: {
depth: 0 | 1
parentPaneKey?: string
isFirstSibling: boolean
isLastSibling: boolean
childCount: number
+130 -1
View File
@@ -14533,7 +14533,136 @@
"clear": "Clear search",
"results": "{{shown}} of {{total}} shown"
},
"settingsLabel": "Agent Dashboard settings"
"settingsLabel": "Agent Dashboard settings",
"orchestrator": {
"trigger": "Orchestrate",
"title": "Orchestrator",
"scope": "Global fleet · {{count}} agents",
"ready": "Ready",
"noLiveAgentStatus": "No live agent",
"commandActive": "Global fleet command active",
"fleetContext": "Fleet context",
"dragHint": "Drag into chat",
"filterFleet": "Filter fleet context",
"filterPlaceholder": "Find…",
"addAgentContext": "Add {{agent}} to orchestration context",
"noFleetMatches": "No matches",
"entireFleet": "Entire fleet",
"dropContext": "Drop a worktree or agent here to narrow context",
"removeContext": "Remove {{context}} from orchestration context",
"hideIntro": "Hide introduction",
"coordinating": "Coordinating…",
"globalOrchestration": "Global orchestration",
"startLiveAgentHint": "Start a live agent to enable orchestration",
"available": "Live route",
"unavailable": "No live route",
"routeThrough": "Route through",
"noLiveAgent": "No live agent available",
"globalContext": "One conversation, fleet-wide context",
"globalContextCopy": "Requests use Orca orchestration to inspect and coordinate agents across projects, workspaces, and hosts.",
"try": "Try",
"suggestions": {
"attention": "What needs my attention across the fleet?",
"summary": "Summarize active work across every project.",
"stalled": "Find agents that may be stalled or running unusually long."
},
"placeholder": "Ask about anything running…",
"composerLabel": "Message the orchestrator",
"sendFailed": "Send failed",
"routesTo": "Routes through {{agent}}",
"startAgentHint": "Start an agent to enable fleet chat",
"openTerminal": "Open coordinator terminal",
"sending": "Sending…",
"send": "Send"
},
"rings": {
"host": {
"all": "All hosts",
"local": "Local",
"ssh": "SSH",
"wsl": "WSL",
"remote": "Remote"
},
"focus": {
"title": "Focus view",
"control": "You decide what stays visible",
"reviewCopy": "Opening a result marks it seen. Only Mark reviewed removes it from Focus.",
"showStates": "Show agent states",
"finished": "Finished",
"finishedResults": "Finished results",
"toReview": "To review",
"lastDay": "Last 24 hours",
"lastWeek": "Last 7 days",
"allFinished": "All finished",
"pinnedVisible": "Pinned results stay visible.",
"hidden": "{{count}} agents hidden",
"reviewVisible": "Mark visible results reviewed",
"showAll": "Show all",
"canvasSummary": "Focus · {{shown}} of {{total}} agents",
"reviewed": "Reviewed result",
"seenPendingReview": "Seen · remains in Focus until reviewed",
"pinned": "Pinned",
"keepVisible": "Keep visible",
"markReviewed": "Mark reviewed"
},
"liveContainmentMap": "Live containment map",
"canvasSummary": "Ownership, activity, and time in one canvas",
"hostFilter": "Host filter",
"liveTerminalPreview": "Live terminal preview",
"durationUnknown": "Duration unavailable",
"durationMinutes": "{{count}}m active",
"durationHours": "{{count}}h active",
"selectedAgent": "Selected agent",
"selectAgentHint": "Select an agent node to pin its details and response draft.",
"inRing": "In ring",
"openLiveTerminal": "Open live terminal",
"respondToAgent": "Respond to agent",
"responsePlaceholder": "Send direction…",
"sendFailed": "Send failed",
"routesToTerminal": "Routes to live terminal",
"terminalUnavailable": "Terminal unavailable",
"sending": "Sending…",
"send": "Send",
"empty": "No agents match the current filters.",
"canvasLabel": "Nested project, workspace, and agent map",
"zoomOut": "Zoom out",
"zoomIn": "Zoom in",
"fit": "Fit",
"state": {
"waiting": "Waiting for input",
"blocked": "Blocked",
"working": "Working",
"done": "Done",
"idle": "Idle"
},
"canvasLayers": "Canvas layers",
"hierarchy": "Project → workspace → agent",
"hierarchyCopy": "Every agent keeps a stable place in its ownership tree.",
"workspaceCount": "{{count}} workspaces",
"agentState": "Agent state",
"agentSize": "Agent size",
"justStarted": "Just started",
"workingLonger": "Working longer",
"sizeScale": "Logarithmic scale · capped after 60m",
"hover": "Hover",
"previewTerminal": "Preview terminal",
"click": "Click",
"pinAgent": "Pin agent",
"doubleClick": "Double-click",
"zoomWorkspace": "Zoom workspace",
"fitFleet": "Fit fleet",
"live": "Live",
"snapshot": "Snapshot",
"clickToPin": "Click agent to pin",
"projectCount": "{{agents}} agents · {{workspaces}} workspaces",
"agentCount": "{{count}} agents"
},
"view": {
"label": "Dashboard view",
"rings": "Agent Rings",
"cells": "Cells",
"board": "Dashboard"
}
},
"dashboard": {
"sidebar": {
+107
View File
@@ -14468,6 +14468,113 @@
"results": "{{shown}} de {{total}} mostrados"
},
"settingsLabel": "Configuración del Panel de agentes",
"orchestrator": {
"trigger": "Orquestar",
"title": "Orquestador",
"scope": "Flota global · {{count}} agentes",
"ready": "Listo",
"noLiveAgentStatus": "No hay agentes activos",
"commandActive": "Comando de flota global activo",
"fleetContext": "Contexto de la flota",
"dragHint": "Arrastra al chat",
"filterFleet": "Filtrar contexto de la flota",
"filterPlaceholder": "Buscar…",
"addAgentContext": "Añadir {{agent}} al contexto de orquestación",
"noFleetMatches": "Sin coincidencias",
"entireFleet": "Toda la flota",
"dropContext": "Suelta aquí un espacio de trabajo o agente para acotar el contexto",
"removeContext": "Quitar {{context}} del contexto de orquestación",
"hideIntro": "Ocultar introducción",
"coordinating": "Coordinando…",
"globalOrchestration": "Orquestación global",
"startLiveAgentHint": "Inicia un agente activo para habilitar la orquestación",
"available": "Ruta activa",
"unavailable": "Sin ruta activa",
"routeThrough": "Enrutar mediante",
"noLiveAgent": "No hay agentes activos disponibles",
"globalContext": "Una conversación, contexto de toda la flota",
"globalContextCopy": "Las solicitudes usan la orquestación de Orca para inspeccionar y coordinar agentes entre proyectos, espacios de trabajo y hosts.",
"try": "Prueba",
"suggestions": {
"attention": "¿Qué necesita mi atención en toda la flota?",
"summary": "Resume el trabajo activo de todos los proyectos.",
"stalled": "Busca agentes bloqueados o activos durante demasiado tiempo."
},
"placeholder": "Pregunta por cualquier tarea en ejecución…",
"composerLabel": "Enviar mensaje al orquestador",
"sendFailed": "Error al enviar",
"routesTo": "Se enruta mediante {{agent}}",
"startAgentHint": "Inicia un agente para habilitar el chat de la flota",
"openTerminal": "Abrir terminal del coordinador",
"sending": "Enviando…",
"send": "Enviar"
},
"rings": {
"host": {
"all": "Todos los hosts",
"local": "Local",
"ssh": "SSH",
"wsl": "WSL",
"remote": "Remoto"
},
"liveContainmentMap": "Mapa de contención en vivo",
"canvasSummary": "Propiedad, actividad y tiempo en un solo lienzo",
"hostFilter": "Filtro de host",
"liveTerminalPreview": "Vista previa de la terminal en vivo",
"durationUnknown": "Duración no disponible",
"durationMinutes": "{{count}} min activo",
"durationHours": "{{count}} h activo",
"selectedAgent": "Agente seleccionado",
"selectAgentHint": "Selecciona un nodo de agente para fijar sus detalles y el borrador de respuesta.",
"inRing": "En el anillo",
"openLiveTerminal": "Abrir terminal en vivo",
"respondToAgent": "Responder al agente",
"responsePlaceholder": "Enviar instrucciones…",
"sendFailed": "Error al enviar",
"routesToTerminal": "Se envía a la terminal en vivo",
"terminalUnavailable": "Terminal no disponible",
"sending": "Enviando…",
"send": "Enviar",
"empty": "Ningún agente coincide con los filtros actuales.",
"canvasLabel": "Mapa anidado de proyectos, espacios de trabajo y agentes",
"zoomOut": "Alejar",
"zoomIn": "Acercar",
"fit": "Ajustar",
"state": {
"waiting": "Esperando entrada",
"blocked": "Bloqueado",
"working": "Trabajando",
"done": "Terminado",
"idle": "Inactivo"
},
"canvasLayers": "Capas del lienzo",
"hierarchy": "Proyecto → espacio de trabajo → agente",
"hierarchyCopy": "Cada agente conserva un lugar estable en su árbol de propiedad.",
"workspaceCount": "{{count}} espacios de trabajo",
"agentState": "Estado del agente",
"agentSize": "Tamaño del agente",
"justStarted": "Recién iniciado",
"workingLonger": "Más tiempo activo",
"sizeScale": "Escala logarítmica · límite a los 60 min",
"hover": "Pasa el cursor",
"previewTerminal": "Vista previa de terminal",
"click": "Clic",
"pinAgent": "Fijar agente",
"doubleClick": "Doble clic",
"zoomWorkspace": "Ampliar espacio",
"fitFleet": "Ajustar flota",
"live": "En vivo",
"snapshot": "Instantánea",
"clickToPin": "Haz clic en el agente para fijarlo",
"projectCount": "{{agents}} agentes · {{workspaces}} espacios de trabajo",
"agentCount": "{{count}} agentes"
},
"view": {
"label": "Vista del panel",
"rings": "Anillos de agentes",
"cells": "Celdas",
"board": "Panel"
},
"settings": {
"showIdle": "Mostrar agentes inactivos",
"showIdleCopy": "Incluye agentes que han permanecido inactivos durante 30 minutos sin informar que finalizaron. Ocultos de forma predeterminada."
+107
View File
@@ -14468,6 +14468,113 @@
"results": "{{total}} 件中 {{shown}} 件を表示"
},
"settingsLabel": "エージェント ダッシュボード設定",
"orchestrator": {
"trigger": "オーケストレーション",
"title": "オーケストレーター",
"scope": "グローバルフリート · {{count}} エージェント",
"ready": "準備完了",
"noLiveAgentStatus": "ライブエージェントなし",
"commandActive": "グローバルフリートコマンドが有効",
"fleetContext": "フリートコンテキスト",
"dragHint": "チャットへドラッグ",
"filterFleet": "フリートコンテキストを絞り込む",
"filterPlaceholder": "検索…",
"addAgentContext": "{{agent}} をオーケストレーションコンテキストに追加",
"noFleetMatches": "一致なし",
"entireFleet": "フリート全体",
"dropContext": "ワークスペースまたはエージェントをドロップして対象を絞り込む",
"removeContext": "{{context}} をオーケストレーションコンテキストから削除",
"hideIntro": "説明を隠す",
"coordinating": "調整中…",
"globalOrchestration": "グローバルオーケストレーション",
"startLiveAgentHint": "オーケストレーションを使うにはライブエージェントを開始してください",
"available": "ライブ経路",
"unavailable": "ライブ経路なし",
"routeThrough": "経由するエージェント",
"noLiveAgent": "利用可能なライブエージェントがありません",
"globalContext": "1つの会話でフリート全体を把握",
"globalContextCopy": "Orca オーケストレーションを使い、プロジェクト、ワークスペース、ホストを横断してエージェントを確認・調整します。",
"try": "例",
"suggestions": {
"attention": "フリート全体で対応が必要なものは?",
"summary": "すべてのプロジェクトの進行中作業を要約して。",
"stalled": "停止しているか、異常に長く実行中のエージェントを探して。"
},
"placeholder": "実行中の作業について質問…",
"composerLabel": "オーケストレーターにメッセージ",
"sendFailed": "送信に失敗しました",
"routesTo": "{{agent}} を経由",
"startAgentHint": "フリートチャットを使うにはエージェントを開始してください",
"openTerminal": "コーディネーターのターミナルを開く",
"sending": "送信中…",
"send": "送信"
},
"rings": {
"host": {
"all": "すべてのホスト",
"local": "ローカル",
"ssh": "SSH",
"wsl": "WSL",
"remote": "リモート"
},
"liveContainmentMap": "ライブ包含マップ",
"canvasSummary": "所有関係、アクティビティ、時間を1つのキャンバスに表示",
"hostFilter": "ホストフィルター",
"liveTerminalPreview": "ライブターミナルプレビュー",
"durationUnknown": "稼働時間を取得できません",
"durationMinutes": "{{count}}分稼働",
"durationHours": "{{count}}時間稼働",
"selectedAgent": "選択中のエージェント",
"selectAgentHint": "エージェントノードを選択すると、詳細と返信の下書きを固定できます。",
"inRing": "リング内",
"openLiveTerminal": "ライブターミナルを開く",
"respondToAgent": "エージェントに返信",
"responsePlaceholder": "指示を送信…",
"sendFailed": "送信に失敗しました",
"routesToTerminal": "ライブターミナルに送信",
"terminalUnavailable": "ターミナルを利用できません",
"sending": "送信中…",
"send": "送信",
"empty": "現在のフィルターに一致するエージェントはありません。",
"canvasLabel": "プロジェクト、ワークスペース、エージェントの入れ子マップ",
"zoomOut": "縮小",
"zoomIn": "拡大",
"fit": "全体表示",
"state": {
"waiting": "入力待ち",
"blocked": "ブロック中",
"working": "作業中",
"done": "完了",
"idle": "アイドル"
},
"canvasLayers": "キャンバスレイヤー",
"hierarchy": "プロジェクト → ワークスペース → エージェント",
"hierarchyCopy": "各エージェントは所有ツリー内の安定した位置を維持します。",
"workspaceCount": "{{count}}件のワークスペース",
"agentState": "エージェントの状態",
"agentSize": "エージェントのサイズ",
"justStarted": "開始直後",
"workingLonger": "長時間稼働",
"sizeScale": "対数スケール · 60分で上限",
"hover": "ホバー",
"previewTerminal": "ターミナルをプレビュー",
"click": "クリック",
"pinAgent": "エージェントを固定",
"doubleClick": "ダブルクリック",
"zoomWorkspace": "ワークスペースを拡大",
"fitFleet": "フリート全体を表示",
"live": "ライブ",
"snapshot": "スナップショット",
"clickToPin": "エージェントをクリックして固定",
"projectCount": "{{agents}}件のエージェント · {{workspaces}}件のワークスペース",
"agentCount": "{{count}}件のエージェント"
},
"view": {
"label": "ダッシュボード表示",
"rings": "エージェントリング",
"cells": "セル",
"board": "ダッシュボード"
},
"settings": {
"showIdle": "アイドル状態のエージェントを表示",
"showIdleCopy": "完了を報告せずに30分間停止しているエージェントを含めます。デフォルトでは非表示です。"
+107
View File
@@ -14468,6 +14468,113 @@
"results": "총 {{total}}개 중 {{shown}}개 표시"
},
"settingsLabel": "에이전트 대시보드 설정",
"orchestrator": {
"trigger": "오케스트레이션",
"title": "오케스트레이터",
"scope": "글로벌 플릿 · 에이전트 {{count}}개",
"ready": "준비됨",
"noLiveAgentStatus": "실행 중인 에이전트 없음",
"commandActive": "글로벌 플릿 명령 활성화됨",
"fleetContext": "플릿 컨텍스트",
"dragHint": "채팅으로 드래그",
"filterFleet": "플릿 컨텍스트 필터",
"filterPlaceholder": "찾기…",
"addAgentContext": "{{agent}}을(를) 오케스트레이션 컨텍스트에 추가",
"noFleetMatches": "일치 항목 없음",
"entireFleet": "전체 플릿",
"dropContext": "워크스페이스나 에이전트를 놓아 컨텍스트 범위를 좁히세요",
"removeContext": "오케스트레이션 컨텍스트에서 {{context}} 제거",
"hideIntro": "소개 숨기기",
"coordinating": "조정 중…",
"globalOrchestration": "글로벌 오케스트레이션",
"startLiveAgentHint": "오케스트레이션을 사용하려면 라이브 에이전트를 시작하세요",
"available": "라이브 경로",
"unavailable": "라이브 경로 없음",
"routeThrough": "경유할 에이전트",
"noLiveAgent": "사용 가능한 라이브 에이전트가 없습니다",
"globalContext": "하나의 대화로 플릿 전체 파악",
"globalContextCopy": "Orca 오케스트레이션을 사용해 프로젝트, 워크스페이스, 호스트 전반의 에이전트를 확인하고 조정합니다.",
"try": "추천",
"suggestions": {
"attention": "플릿 전체에서 내 확인이 필요한 것은?",
"summary": "모든 프로젝트의 진행 중인 작업을 요약해 줘.",
"stalled": "멈췄거나 비정상적으로 오래 실행 중인 에이전트를 찾아 줘."
},
"placeholder": "실행 중인 작업에 대해 질문…",
"composerLabel": "오케스트레이터에게 메시지",
"sendFailed": "보내지 못했습니다",
"routesTo": "{{agent}} 경유",
"startAgentHint": "플릿 채팅을 사용하려면 에이전트를 시작하세요",
"openTerminal": "코디네이터 터미널 열기",
"sending": "보내는 중…",
"send": "보내기"
},
"rings": {
"host": {
"all": "모든 호스트",
"local": "로컬",
"ssh": "SSH",
"wsl": "WSL",
"remote": "원격"
},
"liveContainmentMap": "실시간 포함 관계 맵",
"canvasSummary": "소유 관계, 활동, 시간을 하나의 캔버스에서 확인",
"hostFilter": "호스트 필터",
"liveTerminalPreview": "실시간 터미널 미리보기",
"durationUnknown": "활동 시간을 확인할 수 없음",
"durationMinutes": "{{count}}분 활동",
"durationHours": "{{count}}시간 활동",
"selectedAgent": "선택한 에이전트",
"selectAgentHint": "에이전트 노드를 선택하여 세부 정보와 응답 초안을 고정하세요.",
"inRing": "링 내부",
"openLiveTerminal": "실시간 터미널 열기",
"respondToAgent": "에이전트에게 응답",
"responsePlaceholder": "지시 보내기…",
"sendFailed": "전송 실패",
"routesToTerminal": "실시간 터미널로 전송",
"terminalUnavailable": "터미널을 사용할 수 없음",
"sending": "전송 중…",
"send": "보내기",
"empty": "현재 필터와 일치하는 에이전트가 없습니다.",
"canvasLabel": "프로젝트, 작업 공간, 에이전트의 중첩 맵",
"zoomOut": "축소",
"zoomIn": "확대",
"fit": "맞춤",
"state": {
"waiting": "입력 대기 중",
"blocked": "차단됨",
"working": "작업 중",
"done": "완료",
"idle": "유휴"
},
"canvasLayers": "캔버스 레이어",
"hierarchy": "프로젝트 → 작업 공간 → 에이전트",
"hierarchyCopy": "모든 에이전트는 소유권 트리에서 안정적인 위치를 유지합니다.",
"workspaceCount": "작업 공간 {{count}}개",
"agentState": "에이전트 상태",
"agentSize": "에이전트 크기",
"justStarted": "방금 시작",
"workingLonger": "더 오래 활동",
"sizeScale": "로그 스케일 · 60분 후 상한",
"hover": "가리키기",
"previewTerminal": "터미널 미리보기",
"click": "클릭",
"pinAgent": "에이전트 고정",
"doubleClick": "두 번 클릭",
"zoomWorkspace": "작업 공간 확대",
"fitFleet": "전체 플릿 맞춤",
"live": "실시간",
"snapshot": "스냅샷",
"clickToPin": "에이전트를 클릭하여 고정",
"projectCount": "에이전트 {{agents}}개 · 작업 공간 {{workspaces}}개",
"agentCount": "에이전트 {{count}}개"
},
"view": {
"label": "대시보드 보기",
"rings": "에이전트 링",
"cells": "셀",
"board": "대시보드"
},
"settings": {
"showIdle": "유휴 에이전트 표시",
"showIdleCopy": "완료를 보고하지 않은 채 30분 동안 조용한 에이전트를 포함합니다. 기본적으로 숨겨집니다."
+107
View File
@@ -14468,6 +14468,113 @@
"results": "显示 {{shown}} / {{total}}"
},
"settingsLabel": "智能体仪表盘设置",
"orchestrator": {
"trigger": "全局编排",
"title": "编排器",
"scope": "全局舰队 · {{count}} 个智能体",
"ready": "已就绪",
"noLiveAgentStatus": "没有运行中的智能体",
"commandActive": "全局舰队命令已启用",
"fleetContext": "舰队上下文",
"dragHint": "拖入聊天",
"filterFleet": "筛选舰队上下文",
"filterPlaceholder": "查找…",
"addAgentContext": "将 {{agent}} 添加到编排上下文",
"noFleetMatches": "没有匹配项",
"entireFleet": "整个舰队",
"dropContext": "将工作区或智能体拖到此处以缩小上下文范围",
"removeContext": "从编排上下文中移除 {{context}}",
"hideIntro": "隐藏介绍",
"coordinating": "正在协调…",
"globalOrchestration": "全局编排",
"startLiveAgentHint": "启动一个运行中的智能体以启用编排",
"available": "实时路径",
"unavailable": "无实时路径",
"routeThrough": "经由智能体",
"noLiveAgent": "没有可用的实时智能体",
"globalContext": "一次对话,掌握整个舰队",
"globalContextCopy": "请求会使用 Orca 编排功能,跨项目、工作区和主机检查并协调智能体。",
"try": "试一试",
"suggestions": {
"attention": "整个舰队中有哪些事项需要我处理?",
"summary": "总结所有项目中正在进行的工作。",
"stalled": "查找可能停滞或运行时间异常长的智能体。"
},
"placeholder": "询问任何正在运行的任务…",
"composerLabel": "向编排器发送消息",
"sendFailed": "发送失败",
"routesTo": "经由 {{agent}}",
"startAgentHint": "启动智能体以启用舰队聊天",
"openTerminal": "打开协调器终端",
"sending": "正在发送…",
"send": "发送"
},
"rings": {
"host": {
"all": "所有主机",
"local": "本地",
"ssh": "SSH",
"wsl": "WSL",
"remote": "远程"
},
"liveContainmentMap": "实时包含关系图",
"canvasSummary": "在一个画布中查看所有权、活动和时间",
"hostFilter": "主机筛选",
"liveTerminalPreview": "实时终端预览",
"durationUnknown": "无法获取活动时长",
"durationMinutes": "已活动 {{count}} 分钟",
"durationHours": "已活动 {{count}} 小时",
"selectedAgent": "已选智能体",
"selectAgentHint": "选择智能体节点以固定其详情和回复草稿。",
"inRing": "环内",
"openLiveTerminal": "打开实时终端",
"respondToAgent": "回复智能体",
"responsePlaceholder": "发送指示…",
"sendFailed": "发送失败",
"routesToTerminal": "发送到实时终端",
"terminalUnavailable": "终端不可用",
"sending": "正在发送…",
"send": "发送",
"empty": "没有智能体符合当前筛选条件。",
"canvasLabel": "项目、工作区和智能体的嵌套关系图",
"zoomOut": "缩小",
"zoomIn": "放大",
"fit": "适应",
"state": {
"waiting": "等待输入",
"blocked": "已阻塞",
"working": "工作中",
"done": "已完成",
"idle": "空闲"
},
"canvasLayers": "画布层级",
"hierarchy": "项目 → 工作区 → 智能体",
"hierarchyCopy": "每个智能体在其所有权树中保持稳定位置。",
"workspaceCount": "{{count}} 个工作区",
"agentState": "智能体状态",
"agentSize": "智能体大小",
"justStarted": "刚刚开始",
"workingLonger": "活动时间更长",
"sizeScale": "对数比例 · 60 分钟后封顶",
"hover": "悬停",
"previewTerminal": "预览终端",
"click": "点击",
"pinAgent": "固定智能体",
"doubleClick": "双击",
"zoomWorkspace": "放大工作区",
"fitFleet": "适应整个集群",
"live": "实时",
"snapshot": "快照",
"clickToPin": "点击智能体以固定",
"projectCount": "{{agents}} 个智能体 · {{workspaces}} 个工作区",
"agentCount": "{{count}} 个智能体"
},
"view": {
"label": "仪表盘视图",
"rings": "智能体环",
"cells": "单元格",
"board": "仪表盘"
},
"settings": {
"showIdle": "显示空闲智能体",
"showIdleCopy": "包括已安静 30 分钟且未报告完成的智能体。默认隐藏。"
+9
View File
@@ -38,6 +38,9 @@ export type DashboardCardSubagent = {
dotState: DashboardCardDotState
}
export type DashboardCardHostKind = 'local' | 'ssh' | 'wsl' | 'remote'
export type DashboardCardWorkspaceKind = 'worktree' | 'folder'
export type DashboardCard = {
/** Stable identity for React keys. */
paneKey: string
@@ -58,8 +61,14 @@ export type DashboardCard = {
worktreeId: string
tabId: string
leafId: string | null
/** Direct orchestration parent when both agents are visible in this workspace. */
parentPaneKey?: string
repoName: string
worktreeName: string
/** Optional for preload compatibility with snapshots produced before Fleet Rings. */
hostKind?: DashboardCardHostKind
/** Folder workspaces share the ring hierarchy without pretending to be git worktrees. */
workspaceKind?: DashboardCardWorkspaceKind
workspaceStatusId?: string
workspaceStatusLabel?: string
workspaceStatusColor?: string