mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
checkpoint agent dashboard fleet views
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
+41
@@ -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)
|
||||
})
|
||||
})
|
||||
+46
@@ -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
|
||||
|
||||
+119
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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分間停止しているエージェントを含めます。デフォルトでは非表示です。"
|
||||
|
||||
@@ -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분 동안 조용한 에이전트를 포함합니다. 기본적으로 숨겨집니다."
|
||||
|
||||
@@ -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 分钟且未报告完成的智能体。默认隐藏。"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user