diff --git a/src/renderer/src/components/right-sidebar/OrchestratorMissionControl.spawned-activity.test.tsx b/src/renderer/src/components/right-sidebar/OrchestratorMissionControl.spawned-activity.test.tsx new file mode 100644 index 00000000000..6046eff6cb4 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/OrchestratorMissionControl.spawned-activity.test.tsx @@ -0,0 +1,138 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type MockStore = { + orchestrators: { worktreeId: string; projectName?: string }[] + worktreeLineageById: Record + worktreesByRepo: Record< + string, + { id: string; repoId: string; branch?: string; displayName?: string; path?: string }[] + > + tabsByWorktree: Record + agentStatusByPaneKey: Record + orchestrationActivityByPaneKey: Record + orchestrationRunDagByPaneKey: Record + repos: { id: string; path: string; connectionId?: string }[] + settings: Record + hostedReviewCache: Record +} + +const harness = vi.hoisted(() => ({ + store: {} as MockStore +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (s: MockStore) => T): T => selector(harness.store) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('./MissionControlTasksSection', () => ({ + MissionControlTasksSection: () =>
+})) + +// Why: the card renders its compact header; surfacing `headerLeft` lets us assert +// the live activity line the lineage row injects without mounting the heavy +// Checks panel. +vi.mock('./MissionControlPrReviewCard', () => ({ + MissionControlPrReviewCard: (props: { headerLeft?: React.ReactNode }) => ( +
{props.headerLeft}
+ ) +})) + +vi.mock('./MissionControlPrStatePill', () => ({ PrStatePill: () => })) + +vi.mock('@/lib/orcastrate-log-shipped-work', () => ({ + parseOrchestrateLogOutcomes: () => [], + selectShippedWork: () => [] +})) + +import OrchestratorMissionControl from './OrchestratorMissionControl' + +function baseStore(): MockStore { + return { + orchestrators: [{ worktreeId: 'wt_director', projectName: 'Auth rewrite' }], + // One spawned worker whose lineage parent is the director. + worktreeLineageById: { + wt_worker: { worktreeId: 'wt_worker', parentWorktreeId: 'wt_director', createdAt: 1 } + }, + worktreesByRepo: { + repo1: [ + { id: 'wt_director', repoId: 'repo1', path: '/d', branch: 'main' }, + { id: 'wt_worker', repoId: 'repo1', path: '/w', branch: 'feat/x', displayName: 'worker-1' } + ] + }, + tabsByWorktree: { wt_director: [{ id: 'tab_a' }], wt_worker: [{ id: 'tab_w' }] }, + agentStatusByPaneKey: {}, + orchestrationActivityByPaneKey: {}, + orchestrationRunDagByPaneKey: {}, + repos: [{ id: 'repo1', path: '/repo' }], + settings: {}, + hostedReviewCache: {} + } +} + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + harness.store = baseStore() + ;(globalThis as unknown as { window: { api: unknown } }).window.api = { + fs: { readFile: () => Promise.resolve({ content: '' }) }, + gh: { + repoSlug: () => Promise.resolve(null), + listWorkItems: () => Promise.resolve({ items: [] }) + }, + shell: { openUrl: () => Promise.resolve() } + } + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +async function renderPanel(): Promise { + await act(async () => { + root.render() + }) +} + +describe('OrchestratorMissionControl — Spawned work activity line', () => { + it("renders the worker's live agent activity text under its name", async () => { + harness.store.agentStatusByPaneKey = { + 'tab_w:leaf': { + state: 'working', + prompt: 'Refactoring the auth module', + stateStartedAt: 100 + } + } + await renderPanel() + expect(container.textContent).toContain('worker-1') + expect(container.textContent).toContain('Refactoring the auth module') + }) + + it('falls back to the state label when the live agent reported no prompt', async () => { + harness.store.agentStatusByPaneKey = { + 'tab_w:leaf': { state: 'working', prompt: '', stateStartedAt: 100 } + } + await renderPanel() + expect(container.textContent).toContain('worker-1') + expect(container.textContent).toContain('Working') + }) + + it('omits the activity line when no agent has reported for the worker', async () => { + await renderPanel() + expect(container.textContent).toContain('worker-1') + // No live entry → no activity text (and no state-label fallback) on the row. + expect(container.textContent).not.toContain('Working') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/OrchestratorMissionControl.tsx b/src/renderer/src/components/right-sidebar/OrchestratorMissionControl.tsx index 2259f6c9c59..c07ecadab15 100644 --- a/src/renderer/src/components/right-sidebar/OrchestratorMissionControl.tsx +++ b/src/renderer/src/components/right-sidebar/OrchestratorMissionControl.tsx @@ -3,6 +3,7 @@ import { ExternalLink, GitMerge, Network } from 'lucide-react' import { useAppStore } from '@/store' import { AgentStateDot } from '@/components/AgentStateDot' import { deriveWorktreeAgentDotState } from '@/lib/worktree-agent-dot-state' +import { selectWorktreeAgentActivityText } from '@/lib/worktree-agent-activity-text' import { deriveOrcastratorDotState } from '@/lib/orcastrator-dot-state' import { selectOrchestrationActivityForTabs, @@ -329,6 +330,9 @@ export default function OrchestratorMissionControl({ } const tabIds = (tabsByWorktree[id] ?? []).map((tab) => tab.id) const dot = deriveWorktreeAgentDotState(tabIds, agentStatusByPaneKey) + // Why: mirror DashboardAgentRow's live activity line so a worker + // row shows what its agent is currently doing, not just a dot. + const activityText = selectWorktreeAgentActivityText(tabIds, agentStatusByPaneKey) const prBadge = prBadgeForWorker(worker) const prUrl = prBadge?.url return ( @@ -338,7 +342,17 @@ export default function OrchestratorMissionControl({ headerLeft={ <> - {worker.displayName ?? id} + + {worker.displayName ?? id} + {activityText ? ( + + {activityText} + + ) : null} + } headerRight={ diff --git a/src/renderer/src/lib/worktree-agent-activity-text.ts b/src/renderer/src/lib/worktree-agent-activity-text.ts new file mode 100644 index 00000000000..7a46aceb473 --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-activity-text.ts @@ -0,0 +1,21 @@ +import { agentStateLabel } from '@/components/AgentStateDot' +import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' +import { selectFreshestWorktreeAgentEntry } from '@/lib/worktree-agent-dot-state' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' + +// Why: the lineage "Spawned work" rows show the same live activity line +// DashboardAgentRow derives — the freshest agent's orchestration label/prompt — +// so the text and its live updates match the rest of the app. Falls back to the +// state label when an agent is live but reported no prompt (mirrors +// DashboardAgentRow's empty case); null when no agent has reported for the +// worktree, so the row omits the line instead of showing a placeholder. +export function selectWorktreeAgentActivityText( + tabIds: readonly string[], + agentStatusByPaneKey: Record +): string | null { + const entry = selectFreshestWorktreeAgentEntry(tabIds, agentStatusByPaneKey) + if (!entry) { + return null + } + return getAgentRowPrimaryText(entry) || agentStateLabel(entry.state) +} diff --git a/src/renderer/src/lib/worktree-agent-dot-state.ts b/src/renderer/src/lib/worktree-agent-dot-state.ts index b7d510151ee..c79a8554f84 100644 --- a/src/renderer/src/lib/worktree-agent-dot-state.ts +++ b/src/renderer/src/lib/worktree-agent-dot-state.ts @@ -1,13 +1,13 @@ import type { AgentDotState } from '@/components/AgentStateDot' import type { AgentStatusEntry } from '../../../shared/agent-status-types' -// Why: a worktree's live agent state is the freshest agent-status entry among -// its tabs (paneKey is `${tabId}:${leafId}`). Shared so the Orcastrators sidebar -// and Mission Control render identical dots from one rule, not two copies. -export function deriveWorktreeAgentDotState( +// Why: a worktree's live agent is the freshest agent-status entry among its tabs +// (paneKey is `${tabId}:${leafId}`). Shared so the dot and the per-row activity +// line derive from one rule, not several copies that could drift apart. +export function selectFreshestWorktreeAgentEntry( tabIds: readonly string[], agentStatusByPaneKey: Record -): AgentDotState { +): AgentStatusEntry | null { let latest: AgentStatusEntry | null = null for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { const colon = paneKey.indexOf(':') @@ -18,5 +18,14 @@ export function deriveWorktreeAgentDotState( latest = entry } } - return latest?.state ?? 'idle' + return latest +} + +// Why: shared so the Orcastrators sidebar and Mission Control render identical +// dots from one rule, not two copies. +export function deriveWorktreeAgentDotState( + tabIds: readonly string[], + agentStatusByPaneKey: Record +): AgentDotState { + return selectFreshestWorktreeAgentEntry(tabIds, agentStatusByPaneKey)?.state ?? 'idle' }