Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
worktreesByRepo: Record<
string,
{ id: string; repoId: string; branch?: string; displayName?: string; path?: string }[]
>
tabsByWorktree: Record<string, { id: string }[]>
agentStatusByPaneKey: Record<string, unknown>
orchestrationActivityByPaneKey: Record<string, unknown>
orchestrationRunDagByPaneKey: Record<string, unknown>
repos: { id: string; path: string; connectionId?: string }[]
settings: Record<string, unknown>
hostedReviewCache: Record<string, unknown>
}

const harness = vi.hoisted(() => ({
store: {} as MockStore
}))

vi.mock('@/store', () => ({
useAppStore: <T,>(selector: (s: MockStore) => T): T => selector(harness.store)
}))

vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))

vi.mock('./MissionControlTasksSection', () => ({
MissionControlTasksSection: () => <div data-testid="tasks-section" />
}))

// 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 }) => (
<div data-testid="pr-card">{props.headerLeft}</div>
)
}))

vi.mock('./MissionControlPrStatePill', () => ({ PrStatePill: () => <span /> }))

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<void> {
await act(async () => {
root.render(<OrchestratorMissionControl worktreeId="wt_director" />)
})
}

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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand All @@ -338,7 +342,17 @@ export default function OrchestratorMissionControl({
headerLeft={
<>
<AgentStateDot state={dot} size="sm" />
<span className="min-w-0 flex-1 truncate">{worker.displayName ?? id}</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate">{worker.displayName ?? id}</span>
{activityText ? (
<span
className="truncate text-[11px] leading-snug text-muted-foreground"
title={activityText}
>
{activityText}
</span>
) : null}
</span>
</>
}
headerRight={
Expand Down
21 changes: 21 additions & 0 deletions src/renderer/src/lib/worktree-agent-activity-text.ts
Original file line number Diff line number Diff line change
@@ -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, AgentStatusEntry>
): string | null {
const entry = selectFreshestWorktreeAgentEntry(tabIds, agentStatusByPaneKey)
if (!entry) {
return null
}
return getAgentRowPrimaryText(entry) || agentStateLabel(entry.state)
}
21 changes: 15 additions & 6 deletions src/renderer/src/lib/worktree-agent-dot-state.ts
Original file line number Diff line number Diff line change
@@ -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<string, AgentStatusEntry>
): AgentDotState {
): AgentStatusEntry | null {
let latest: AgentStatusEntry | null = null
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
const colon = paneKey.indexOf(':')
Expand All @@ -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<string, AgentStatusEntry>
): AgentDotState {
return selectFreshestWorktreeAgentEntry(tabIds, agentStatusByPaneKey)?.state ?? 'idle'
}