From 2e7d196074062a8dce2dcbb1361540ea3b0983c3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:51:06 -0700 Subject: [PATCH] checkpoint agent dashboard fleet views --- .../ipc/dashboard-payload-validation.test.ts | 21 + src/main/ipc/dashboard-payload-validation.ts | 8 + .../window/dashboard-popout-window.test.ts | 6 + src/main/window/dashboard-popout-window.ts | 2 +- .../dashboard-popout/AgentCellMap.test.tsx | 105 +++++ .../dashboard-popout/AgentCellMap.tsx | 275 ++++++++++++ .../AgentDashboardToolbar.tsx | 18 +- .../dashboard-popout/AgentFleetRings.test.tsx | 344 +++++++++++++++ .../dashboard-popout/AgentFleetRings.tsx | 240 +++++++++++ .../AgentKanbanBoard.test.tsx | 174 +++++++- .../dashboard-popout/AgentKanbanBoard.tsx | 196 +++++++-- .../dashboard-popout/AgentKanbanCard.tsx | 3 + .../AgentTerminalDialog.test.tsx | 60 ++- .../dashboard-popout/AgentTerminalDialog.tsx | 245 ++++++++--- .../dashboard-popout/AgentTerminalPreview.tsx | 9 +- .../DashboardOrchestratorChat.test.tsx | 210 ++++++++++ .../DashboardOrchestratorChat.tsx | 262 ++++++++++++ .../DashboardOrchestratorConversation.tsx | 265 ++++++++++++ .../DashboardOrchestratorFleetRail.tsx | 199 +++++++++ .../dashboard-popout/DashboardPopoutRoot.tsx | 9 +- .../dashboard-popout/FleetAgentInspector.tsx | 205 +++++++++ .../dashboard-popout/FleetRingsCanvas.tsx | 394 ++++++++++++++++++ .../dashboard-popout/FleetRingsFocusRail.tsx | 226 ++++++++++ .../dashboard-popout/FleetRingsLegend.tsx | 157 +++++++ .../dashboard-popout/FleetRingsMap.tsx | 213 ++++++++++ .../FleetTerminalHoverPreview.tsx | 60 +++ .../FleetTerminalStreamPreview.test.tsx | 116 ++++++ .../FleetTerminalStreamPreview.tsx | 171 ++++++++ .../dashboard-popout/agent-cell-map.css | 133 ++++++ .../dashboard-orchestrator-context.ts | 149 +++++++ ...board-orchestrator-message-history.test.ts | 41 ++ .../dashboard-orchestrator-message-history.ts | 46 ++ .../fleet-agent-lineage-layout.ts | 130 ++++++ .../fleet-rings-focus-filter.test.ts | 84 ++++ .../fleet-rings-focus-filter.ts | 116 ++++++ .../fleet-rings-layout.test.ts | 269 ++++++++++++ .../dashboard-popout/fleet-rings-layout.ts | 314 ++++++++++++++ .../dashboard-popout/fleet-rings.css | 248 +++++++++++ .../fleet-worktree-packing.ts | 163 ++++++++ .../use-fleet-result-disposition.test.tsx | 84 ++++ .../use-fleet-result-disposition.ts | 170 ++++++++ .../dashboard/AgentDashboardDrawer.tsx | 1 + .../components/dashboard/agent-row-lineage.ts | 2 + ...ashboard-snapshot-folder-workspace.test.ts | 119 ++++++ .../build-dashboard-snapshot.test.ts | 58 +++ .../dashboard/build-dashboard-snapshot.ts | 49 ++- .../dashboard/dashboard-card-context.ts | 6 +- .../dashboard-snapshot-workspaces.ts | 101 +++++ .../components/dashboard/useDashboardData.ts | 1 + src/renderer/src/i18n/locales/en.json | 131 +++++- src/renderer/src/i18n/locales/es.json | 107 +++++ src/renderer/src/i18n/locales/ja.json | 107 +++++ src/renderer/src/i18n/locales/ko.json | 107 +++++ src/renderer/src/i18n/locales/zh.json | 107 +++++ src/shared/dashboard-snapshot.ts | 9 + 55 files changed, 6930 insertions(+), 115 deletions(-) create mode 100644 src/renderer/src/components/dashboard-popout/AgentCellMap.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentCellMap.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentFleetRings.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentFleetRings.tsx create mode 100644 src/renderer/src/components/dashboard-popout/DashboardOrchestratorChat.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/DashboardOrchestratorChat.tsx create mode 100644 src/renderer/src/components/dashboard-popout/DashboardOrchestratorConversation.tsx create mode 100644 src/renderer/src/components/dashboard-popout/DashboardOrchestratorFleetRail.tsx create mode 100644 src/renderer/src/components/dashboard-popout/FleetAgentInspector.tsx create mode 100644 src/renderer/src/components/dashboard-popout/FleetRingsCanvas.tsx create mode 100644 src/renderer/src/components/dashboard-popout/FleetRingsFocusRail.tsx create mode 100644 src/renderer/src/components/dashboard-popout/FleetRingsLegend.tsx create mode 100644 src/renderer/src/components/dashboard-popout/FleetRingsMap.tsx create mode 100644 src/renderer/src/components/dashboard-popout/FleetTerminalHoverPreview.tsx create mode 100644 src/renderer/src/components/dashboard-popout/FleetTerminalStreamPreview.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/FleetTerminalStreamPreview.tsx create mode 100644 src/renderer/src/components/dashboard-popout/agent-cell-map.css create mode 100644 src/renderer/src/components/dashboard-popout/dashboard-orchestrator-context.ts create mode 100644 src/renderer/src/components/dashboard-popout/dashboard-orchestrator-message-history.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/dashboard-orchestrator-message-history.ts create mode 100644 src/renderer/src/components/dashboard-popout/fleet-agent-lineage-layout.ts create mode 100644 src/renderer/src/components/dashboard-popout/fleet-rings-focus-filter.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/fleet-rings-focus-filter.ts create mode 100644 src/renderer/src/components/dashboard-popout/fleet-rings-layout.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/fleet-rings-layout.ts create mode 100644 src/renderer/src/components/dashboard-popout/fleet-rings.css create mode 100644 src/renderer/src/components/dashboard-popout/fleet-worktree-packing.ts create mode 100644 src/renderer/src/components/dashboard-popout/use-fleet-result-disposition.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/use-fleet-result-disposition.ts create mode 100644 src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts create mode 100644 src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index 954723a6182..474db1f0f0c 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -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', () => { diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index 0dfb80648d9..1a70c1423b4 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -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) && diff --git a/src/main/window/dashboard-popout-window.test.ts b/src/main/window/dashboard-popout-window.test.ts index 0976a922e16..db27409b425 100644 --- a/src/main/window/dashboard-popout-window.test.ts +++ b/src/main/window/dashboard-popout-window.test.ts @@ -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) diff --git a/src/main/window/dashboard-popout-window.ts b/src/main/window/dashboard-popout-window.ts index 32fb00008ff..f5f21221485 100644 --- a/src/main/window/dashboard-popout-window.ts +++ b/src/main/window/dashboard-popout-window.ts @@ -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" diff --git a/src/renderer/src/components/dashboard-popout/AgentCellMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentCellMap.test.tsx new file mode 100644 index 00000000000..8158a1b97e1 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentCellMap.test.tsx @@ -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 { + 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 { + return render( + + + + ) +} + +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() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentCellMap.tsx b/src/renderer/src/components/dashboard-popout/AgentCellMap.tsx new file mode 100644 index 00000000000..30fd8544833 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentCellMap.tsx @@ -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 = { + 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() + for (const card of cards) { + projects.set(card.repoId, [...(projects.get(card.repoId) ?? []), card]) + } + return [...projects.entries()] + .map(([id, projectCards]) => { + const worktrees = new Map() + 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 ( + + + + + + {agentName(card)} + {agentStateLabel(card.dotState)} + + + ) +} + +export function AgentCellMap({ + cards, + now, + className, + selectedPaneKey = null, + onOpenTerminal +}: AgentCellMapProps): React.JSX.Element { + const projects = useMemo(() => buildProjectCells(cards), [cards]) + const nodeRefs = useRef(new Map()) + 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 ( +
+ {translate('dashboardPopout.rings.empty', 'No agents match the current filters.')} +
+ ) + } + + return ( +
+
+ {projects.map((project) => ( +
+
+

{project.name}

+ + {translate( + 'dashboardPopout.rings.projectCount', + '{{agents}} agents · {{workspaces}} workspaces', + { + agents: project.agentCount, + workspaces: project.worktrees.length + } + )} + +
+
+ {project.worktrees.map((worktree) => ( +
card.paneKey === selectedPaneKey) && 'is-selected' + )} + data-testid="agent-cell-worktree" + > +
+

+ {worktree.name} +

+ + {formatResponseAge(worktree.lastResponseAt, now)} + +
+
+ {worktree.cards.map((card) => ( + setNodeRef(card.paneKey, node)} + onOpenTerminal={onOpenTerminal} + /> + ))} +
+
+ ))} +
+
+ ))} +
+
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx b/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx index 6bb0f4d6a61..afcdfef326a 100644 --- a/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentDashboardToolbar.tsx @@ -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 } 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({
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 ? ( - ) : null} + ) : ( + + )}
{query || activeCount > 0 ? ( diff --git a/src/renderer/src/components/dashboard-popout/AgentFleetRings.test.tsx b/src/renderer/src/components/dashboard-popout/AgentFleetRings.test.tsx new file mode 100644 index 00000000000..daad0759a56 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentFleetRings.test.tsx @@ -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 { + 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(), + pinnedPaneKeys = new Set(), + onMarkReviewed = vi.fn(), + onOpenTerminal = vi.fn(), + selectedPaneKey = null, + compact = false + }: { + reviewedPaneKeys?: ReadonlySet + pinnedPaneKeys?: ReadonlySet + onMarkReviewed?: (cards: DashboardCard[]) => void + onOpenTerminal?: (card: DashboardCard, side: 'left' | 'right') => void + selectedPaneKey?: string | null + compact?: boolean + } = {} +): ReturnType { + return render( + + ) +} + +describe('AgentFleetRings', () => { + const originalUserAgent = navigator.userAgent + let boundsSpy: ReturnType + + 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('.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) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentFleetRings.tsx b/src/renderer/src/components/dashboard-popout/AgentFleetRings.tsx new file mode 100644 index 00000000000..c7a2c099982 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentFleetRings.tsx @@ -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 + reviewedPaneKeys: ReadonlySet + 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(null) + const layoutCacheRef = useRef(null) + const [hostFilter, setHostFilter] = useState('all') + const [enabledStates, setEnabledStates] = useState>( + () => new Set(DEFAULT_STATES) + ) + const [finishedScope, setFinishedScope] = useState('review') + const [hiddenProjectIds, setHiddenProjectIds] = useState>(() => new Set()) + const hostCounts = useMemo(() => { + const counts: Record = { + 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() + 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 ( +
+ {!compact ? ( + canvasRef.current?.fit()} + onMarkReviewed={onMarkReviewed} + onProjectToggle={toggleProject} + onShowAll={showAll} + onStateToggle={toggleState} + /> + ) : null} +
+
+ + + {translate('dashboardPopout.rings.liveContainmentMap', 'Live containment map')} + + + {translate( + 'dashboardPopout.rings.focus.canvasSummary', + 'Focus · {{shown}} of {{total}} agents', + { + shown: visibleCards.length, + total: cards.length + } + )} + + + {!compact ? ( +
+ {HOST_FILTERS.filter((option) => option === 'all' || hostCounts[option] > 0).map( + (option) => ( + + ) + )} +
+ ) : null} +
+ +
+
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx index f9390570e30..8553819af8b 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx @@ -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 }) => (
+ ), + 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 + }) => ( +
+
) })) @@ -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() + + 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() + + 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( + + ) + + 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( + + ) + expect(screen.getByRole('button', { name: /Fresh result/ })).toBeInTheDocument() + + view.unmount() + const reopened = render( + + ) + 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( + + ) + expect(screen.queryByRole('button', { name: /Fresh result/ })).not.toBeInTheDocument() + }) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx index ded2be0c221..5eb6ced1b35 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx @@ -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(null) const [filters, setFilters] = useState(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. -
+

{translate('dashboardPopout.title', 'Agents')} @@ -231,6 +279,45 @@ export function AgentKanbanBoard({ count: visibleCards.length })} +
+ + + +
{headerActions || onClose ? (
{headerActions} @@ -255,27 +342,86 @@ export function AgentKanbanBoard({ onQueryChange={setQuery} filters={filters} onFiltersChange={setFilters} + searchInputRef={searchInputRef} /> -
- {/* Auto margins center the capped board and collapse during horizontal overflow. */} -
- {visibleBuckets.map((bucket) => ( - + {view === 'rings' ? ( + - ))} + ) : ( + + )} + {dialogCard ? ( + 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}
-
- + ) : ( +
+ {/* Auto margins center the capped board and collapse during horizontal overflow. */} +
+ {visibleBuckets.map((bucket) => ( + + ))} +
+
+ )} + {view === 'board' ? ( + + ) : null} +
) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx index 556d5f563c1..fbaadfdbb78 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -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 && diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx index 6610efe2605..6e7b88a0377 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx @@ -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 }) => (
) })) @@ -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( + {}} + 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( + {}} + 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( {}} 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() + }) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx index ead43090820..7ced4ca791a 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx @@ -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 & { + 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 ( + <> +
+ + + + {title} + + {formatAgentTypeLabel(card.agentType)} · {agentStateLabel(card.dotState)} + + +
+ {card.ptyId ? ( + + ) : ( +
+ {translate( + 'dashboardPopout.terminal.closed', + "No live terminal — this agent's pane has closed." + )} +
+ )} +
+ {card.dotState === 'done' && onMarkReviewed && onTogglePinned ? ( + <> + + {reviewed + ? translate('dashboardPopout.rings.focus.reviewed', 'Reviewed result') + : translate( + 'dashboardPopout.rings.focus.seenPendingReview', + 'Seen · remains in Focus until reviewed' + )} + + + {!reviewed ? ( + + ) : null} + + ) : null} + +
+ + ) } /** @@ -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 ( {card ? ( @@ -78,43 +187,77 @@ export function AgentTerminalDialog({ } }} > -
- - - - - {card.worktreeName} - - - {formatAgentTypeLabel(card.agentType)} · {agentStateLabel(card.dotState)} - - - - - {translate('dashboardPopout.terminal.close', 'Close')} - - -
- {card.ptyId ? ( - - ) : ( -
- {translate( - 'dashboardPopout.terminal.closed', - "No live terminal — this agent's pane has closed." - )} -
- )} -
- - - -
+ + {card.worktreeName} + + } + onOpenChange={onOpenChange} + onReveal={onReveal} + reviewed={reviewed} + pinned={pinned} + onMarkReviewed={onMarkReviewed} + onTogglePinned={onTogglePinned} + /> ) : null}
) } + +export function AgentTerminalPanel({ + card, + onOpenChange, + onReveal, + reviewed = false, + pinned = false, + onMarkReviewed, + onTogglePinned, + className +}: Omit & { + 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 ( +
+ + {card.worktreeName} +

+ } + previewClassName="h-auto min-h-0 flex-1" + onOpenChange={onOpenChange} + onReveal={onReveal} + reviewed={reviewed} + pinned={pinned} + onMarkReviewed={onMarkReviewed} + onTogglePinned={onTogglePinned} + /> + + ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index 8da354166f7..50d65df9193 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -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(null) const terminalRef = useRef(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.
{ptyGone ? ( diff --git a/src/renderer/src/components/dashboard-popout/DashboardOrchestratorChat.test.tsx b/src/renderer/src/components/dashboard-popout/DashboardOrchestratorChat.test.tsx new file mode 100644 index 00000000000..91381b5c0b2 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/DashboardOrchestratorChat.test.tsx @@ -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 }) => +})) + +function card(overrides: Partial = {}): 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() + 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( + + ) + + 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() + + expect(buildProjects).not.toHaveBeenCalled() + rerender() + 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() + 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() + 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() + 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( + + ) + 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( + + ) + 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() + + 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( + + ) + + 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() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/DashboardOrchestratorChat.tsx b/src/renderer/src/components/dashboard-popout/DashboardOrchestratorChat.tsx new file mode 100644 index 00000000000..9e98f7ca0f0 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/DashboardOrchestratorChat.tsx @@ -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() + +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(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([]) + const [messages, setMessages] = useState([]) + const [pendingReply, setPendingReply] = useState(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 => { + 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 ( + + + + + { + 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" + > +
+ + + + + + {translate('dashboardPopout.orchestrator.title', 'Orchestrator')} + + + {translate('dashboardPopout.orchestrator.scope', 'Global fleet · {{count}} agents', { + count: cards.length + })} + + + + + {target + ? translate('dashboardPopout.orchestrator.ready', 'Ready') + : translate('dashboardPopout.orchestrator.noLiveAgentStatus', 'No live agent')} + +
+
+ + {ORCHESTRATION_COMMAND} + + + {translate('dashboardPopout.orchestrator.commandActive', 'Global fleet command active')} + +
+
+ + setIntroVisible(false)} + onSend={() => void send()} + /> +
+
+
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/DashboardOrchestratorConversation.tsx b/src/renderer/src/components/dashboard-popout/DashboardOrchestratorConversation.tsx new file mode 100644 index 00000000000..5dcbe2c9af7 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/DashboardOrchestratorConversation.tsx @@ -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 + 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 ( +
+
{ + 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)) + }} + > +
+
+ {contexts.length === 0 ? ( + <> + + {translate('dashboardPopout.orchestrator.entireFleet', 'Entire fleet')} + + + {translate( + 'dashboardPopout.orchestrator.dropContext', + 'Drop a worktree or agent here to narrow context' + )} + + + ) : ( + contexts.map((context) => ( + + {context.label} + + + )) + )} +
+
+ + +
+ {introVisible ? ( +
+ + + + {translate( + 'dashboardPopout.orchestrator.globalContext', + 'One conversation, fleet-wide context' + )} + +

+ {translate( + 'dashboardPopout.orchestrator.globalContextCopy', + 'Requests use Orca orchestration to inspect and coordinate agents across projects, workspaces, and hosts.' + )} +

+
+ ) : null} + + {messages.map((message) => + message.role === 'user' ? ( +
+ {message.text} +
+ ) : ( +
+ {message.text} +
+ ) + )} + + {pendingReply ? ( +
+ + {translate('dashboardPopout.orchestrator.coordinating', 'Coordinating…')} +
+ ) : null} + + {messages.length === 0 ? ( +
+ + {translate('dashboardPopout.orchestrator.try', 'Try')} + +
+ {SUGGESTIONS.map(([id, fallback]) => ( + + ))} +
+
+ ) : null} +
+
+
+ +