diff --git a/src/main/index.ts b/src/main/index.ts
index 2c86576615b..ee2592fcb70 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -453,6 +453,7 @@ function openMainWindow(): BrowserWindow {
if (mainWindow?.isDestroyed()) {
return
}
+ const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey)
mainWindow?.webContents.send('agentStatus:set', {
...payload,
paneKey,
@@ -460,7 +461,8 @@ function openMainWindow(): BrowserWindow {
worktreeId,
connectionId,
receivedAt,
- stateStartedAt
+ stateStartedAt,
+ ...(orchestration ? { orchestration } : {})
})
recordCrashBreadcrumb('agent_state_changed', {
agentType: payload.agentType ?? 'unknown',
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index 88f4f5a75e4..2bec64427d5 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -7,6 +7,7 @@ import {
isShellProcess
} from '../../shared/agent-detection'
import type { AgentStatus } from '../../shared/agent-detection'
+import type { AgentStatusOrchestrationContext } from '../../shared/agent-status-types'
import { gitExecFileAsync, wslAwareSpawn } from '../git/runner'
import { isWslPath, parseWslPath, getWslHome } from '../wsl'
import { createHash, randomUUID } from 'crypto'
@@ -10183,6 +10184,67 @@ export class OrcaRuntimeService {
}
}
+ getAgentStatusOrchestrationContextForPaneKey(
+ paneKey: string
+ ): AgentStatusOrchestrationContext | undefined {
+ const handle = this.getTerminalHandleForPaneKey(paneKey)
+ if (!handle) {
+ return undefined
+ }
+ const db = this.getOrchestrationDbIfAvailable()
+ const dispatch = db?.getActiveDispatchForTerminal(handle)
+ if (!dispatch) {
+ return undefined
+ }
+ const task = db?.getTask(dispatch.task_id)
+ const activeRun = db?.getActiveCoordinatorRun()
+ const parentTerminalHandle =
+ task?.created_by_terminal_handle ??
+ (activeRun?.coordinator_handle && activeRun.coordinator_handle !== handle
+ ? activeRun.coordinator_handle
+ : undefined)
+ const parentPaneKey = parentTerminalHandle
+ ? this.getPaneKeyForTerminalHandle(parentTerminalHandle)
+ : undefined
+
+ return {
+ taskId: dispatch.task_id,
+ dispatchId: dispatch.id,
+ ...(parentTerminalHandle ? { parentTerminalHandle } : {}),
+ ...(parentPaneKey ? { parentPaneKey } : {}),
+ ...(activeRun?.coordinator_handle ? { coordinatorHandle: activeRun.coordinator_handle } : {}),
+ ...(activeRun?.id ? { orchestrationRunId: activeRun.id } : {})
+ }
+ }
+
+ private getTerminalHandleForPaneKey(paneKey: string): string | null {
+ const parsed = parsePaneKey(paneKey)
+ if (parsed) {
+ const leaf = this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId))
+ if (leaf?.ptyId) {
+ return this.issueHandle(leaf)
+ }
+ }
+ for (const pty of this.ptysById.values()) {
+ if (pty.paneKey === paneKey) {
+ return this.issuePtyHandle(pty)
+ }
+ }
+ return null
+ }
+
+ private getPaneKeyForTerminalHandle(handle: string): string | null {
+ const livePty = this.getLivePtyForHandle(handle)
+ if (livePty?.pty.paneKey) {
+ return livePty.pty.paneKey
+ }
+ const record = this.handles.get(handle)
+ if (!record || record.runtimeId !== this.runtimeId) {
+ return null
+ }
+ return makePaneKey(record.tabId, record.leafId)
+ }
+
// Why: OSC title detection via onPtyData is the tightest signal for agent
// presence, but the runtime may not see PTY data for daemon-hosted terminals
// (the daemon adapter stubs getForegroundProcess). This checks three signals
diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx
index 35df7ec2716..9769b5bb48c 100644
--- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx
+++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx
@@ -173,4 +173,54 @@ describe('DashboardAgentRow', () => {
expect(interruptedIndex).toBeGreaterThan(promptIndex)
expect(markup).not.toContain('lucide-circle-check')
})
+
+ it('renders orchestration child rows with a connector and tree level', () => {
+ const markup = renderRow(
+ makeAgent({
+ lineage: {
+ depth: 1,
+ isFirstSibling: true,
+ isLastSibling: true,
+ childCount: 0
+ }
+ })
+ )
+
+ expect(markup).toContain('data-agent-lineage-connector="last"')
+ expect(markup).toContain('role="treeitem"')
+ expect(markup).toContain('aria-level="2"')
+ expect(classTokens(markup)).toContain('pl-5')
+ expect(classTokens(markup)).toContain('left-[13px]')
+ expect(classTokens(markup)).toContain('top-[0.7rem]')
+ expect(classTokens(markup)).toContain('w-1.5')
+ expect(classTokens(markup)).toContain('border-l-[1.5px]')
+ expect(classTokens(markup)).toContain('border-t-[1.5px]')
+ expect(classTokens(markup)).toContain('border-muted-foreground/45')
+ })
+
+ it('annotates parent identity icon when it dispatched children', () => {
+ const markup = renderToStaticMarkup(
+
+
+
+ )
+
+ expect(markup).toContain('title="Codex - dispatched 2 agents"')
+ expect(markup).toContain('data-agent-lineage-parent-connector="true"')
+ expect(classTokens(markup)).toContain('left-[13px]')
+ expect(markup).toContain('aria-level="1"')
+ })
})
diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
index 393833234fd..dc25b2706c6 100644
--- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
+++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
@@ -185,6 +185,16 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
const toolInput = isWorking ? (agent.entry.toolInput?.trim() ?? '') : ''
const lastAssistantMessage = agent.entry.lastAssistantMessage?.trim() ?? ''
const isInterrupted = agent.entry.interrupted === true
+ const lineage = agent.lineage
+ const isLineageChild = lineage?.depth === 1
+ const lineageChildCount = lineage?.childCount ?? 0
+ const participatesInLineage = isLineageChild || lineageChildCount > 0
+ const identityTitle =
+ lineageChildCount > 0
+ ? `${formatAgentTypeLabel(agent.agentType)} - dispatched ${lineageChildCount} ${
+ lineageChildCount === 1 ? 'agent' : 'agents'
+ }`
+ : formatAgentTypeLabel(agent.agentType)
// Why: interrupted is a terminal outcome the user needs to scan in the
// leading state column; the secondary-line text below provides the
// explanation without competing with the prompt or timestamp.
@@ -218,7 +228,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
className={cn(
// Why: this row owns the timestamp/X hover boundary; anonymous
// ancestor groups from workspace cards must not reveal every row's X.
- 'group/agent-row relative flex flex-col -ml-2 px-2 py-1',
+ 'group/agent-row relative flex flex-col -ml-2 py-1',
+ isLineageChild ? 'pl-5 pr-2' : 'px-2',
// Why: hover tints have to go in opposite directions per theme —
// dark mode adds light on dark (bg-accent/30), light mode needs to
// add *dark* on white. Alpha-on-accent in light mode collapses to
@@ -228,7 +239,36 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
'cursor-pointer rounded-sm hover:bg-black/[0.06] dark:hover:bg-accent/30'
)}
title={tsParts.length > 0 ? tsParts.join(' • ') : undefined}
+ role={participatesInLineage ? 'treeitem' : undefined}
+ aria-level={participatesInLineage ? (lineage?.depth ?? 0) + 1 : undefined}
>
+ {lineageChildCount > 0 ? (
+
+ ) : null}
+ {isLineageChild ? (
+
+
+
+
+ ) : null}
{/* Why: state indicator lives in the leading gutter so the user's
eye can sweep one column and know which rows are working,
@@ -257,7 +297,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
them — keeping the icon only on the prompt row lets the sub-rows
indent under the prompt text cleanly. */}
{!hideIdentityIcon && (
-
+
)}
diff --git a/src/renderer/src/components/dashboard/agent-row-lineage.ts b/src/renderer/src/components/dashboard/agent-row-lineage.ts
new file mode 100644
index 00000000000..51aa8cf5651
--- /dev/null
+++ b/src/renderer/src/components/dashboard/agent-row-lineage.ts
@@ -0,0 +1,89 @@
+import type { DashboardAgentRow } from './useDashboardData'
+
+export type AgentRowLineagePresentation = {
+ depth: 0 | 1
+ isFirstSibling: boolean
+ isLastSibling: boolean
+ childCount: number
+}
+
+export type DashboardAgentRowWithLineage = DashboardAgentRow & {
+ lineage: AgentRowLineagePresentation
+}
+
+const ROOT_LINEAGE: AgentRowLineagePresentation = {
+ depth: 0,
+ isFirstSibling: true,
+ isLastSibling: true,
+ childCount: 0
+}
+
+export function applyAgentRowLineage(rows: DashboardAgentRow[]): DashboardAgentRowWithLineage[] {
+ if (rows.length <= 1) {
+ return rows.map((row) => ({ ...row, lineage: ROOT_LINEAGE }))
+ }
+
+ const rowsByPaneKey = new Map(rows.map((row) => [row.paneKey, row]))
+ const childrenByParentPaneKey = new Map()
+ const childPaneKeys = new Set()
+
+ for (const row of rows) {
+ const parentPaneKey = row.entry.orchestration?.parentPaneKey
+ if (!parentPaneKey || !rowsByPaneKey.has(parentPaneKey)) {
+ continue
+ }
+ childPaneKeys.add(row.paneKey)
+ const siblings = childrenByParentPaneKey.get(parentPaneKey)
+ if (siblings) {
+ siblings.push(row)
+ } else {
+ childrenByParentPaneKey.set(parentPaneKey, [row])
+ }
+ }
+
+ if (childPaneKeys.size === 0) {
+ return rows.map((row) => ({ ...row, lineage: ROOT_LINEAGE }))
+ }
+
+ const ordered: DashboardAgentRowWithLineage[] = []
+ const emitted = new Set()
+ const emitRow = (row: DashboardAgentRow, lineage: AgentRowLineagePresentation): boolean => {
+ if (emitted.has(row.paneKey)) {
+ return false
+ }
+ emitted.add(row.paneKey)
+ ordered.push({ ...row, lineage })
+ return true
+ }
+
+ const emitSubtree = (row: DashboardAgentRow, lineage: AgentRowLineagePresentation): void => {
+ const children = childrenByParentPaneKey.get(row.paneKey) ?? []
+ if (!emitRow(row, { ...lineage, childCount: children.length })) {
+ return
+ }
+ children.forEach((child, index) => {
+ emitSubtree(child, {
+ // Why: nested dispatches should still stay under their nearest
+ // visible parent, but visual depth remains capped so dense sidebar
+ // rows don't lose too much prompt width.
+ depth: 1,
+ isFirstSibling: index === 0,
+ isLastSibling: index === children.length - 1,
+ childCount: 0
+ })
+ })
+ }
+
+ for (const row of rows) {
+ if (childPaneKeys.has(row.paneKey)) {
+ continue
+ }
+ emitSubtree(row, ROOT_LINEAGE)
+ }
+
+ for (const row of rows) {
+ emitRow(row, ROOT_LINEAGE)
+ }
+
+ return ordered
+}
diff --git a/src/renderer/src/components/dashboard/useDashboardData.ts b/src/renderer/src/components/dashboard/useDashboardData.ts
index 634bfb98e59..bb876573d69 100644
--- a/src/renderer/src/components/dashboard/useDashboardData.ts
+++ b/src/renderer/src/components/dashboard/useDashboardData.ts
@@ -24,6 +24,12 @@ export type DashboardAgentRow = {
* stateHistory entry, falling back to updatedAt when no history exists yet.
* Used to sort agents by when they started. */
startedAt: number
+ lineage?: {
+ depth: 0 | 1
+ isFirstSibling: boolean
+ isLastSibling: boolean
+ childCount: number
+ }
}
// Why: the shape here is deliberately minimal. The per-card rendering pipeline
diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
index 11e616083bd..e660634ac99 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
@@ -122,6 +122,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
// never mount this component (see WorktreeCardAgents), so idle worktrees
// don't pay any timer cost.
const now = useNow(30_000)
+ const hasLineage = agents.some((agent) => agent.lineage && agent.lineage.depth > 0)
const stopBubble = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
@@ -134,7 +135,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
className={cn('flex flex-col mt-1 mb-1 divide-y divide-border/30', className)}
onClick={stopBubble}
onDoubleClick={stopBubble}
- role="group"
+ role={hasLineage ? 'tree' : 'group'}
aria-label="Agents"
>
{agents.map((agent) => (
diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts
index 2bd1a120e7f..99283baa9b6 100644
--- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts
+++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts
@@ -12,11 +12,14 @@ import {
selectMigrationUnsupportedEntriesForWorktree,
selectRetainedAgentEntriesForWorktree
} from './useWorktreeAgentRows'
+import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage'
import { makePaneKey } from '../../../../shared/stable-pane-id'
const ORPHAN_PANE_KEY = makePaneKey('tab-orphan', '11111111-1111-4111-8111-111111111111')
const PANE_KEY_1 = makePaneKey('tab-1', '22222222-2222-4222-8222-222222222222')
const PANE_KEY_2 = makePaneKey('tab-2', '33333333-3333-4333-8333-333333333333')
+const PANE_KEY_3 = makePaneKey('tab-3', '55555555-5555-4555-8555-555555555555')
+const PANE_KEY_4 = makePaneKey('tab-4', '66666666-6666-4666-8666-666666666666')
function makeTab(id: string): TerminalTab {
return {
@@ -115,6 +118,110 @@ describe('buildWorktreeAgentRows', () => {
})
})
+describe('applyAgentRowLineage', () => {
+ it('places orchestration children immediately after their parent', () => {
+ const parent = makeEntry(PANE_KEY_2, 2000, {
+ prompt: 'parent'
+ })
+ const firstChild = makeEntry(PANE_KEY_1, 1000, {
+ prompt: 'first child',
+ orchestration: {
+ taskId: 'task-1',
+ dispatchId: 'ctx-1',
+ parentTerminalHandle: 'term-parent',
+ parentPaneKey: PANE_KEY_2
+ }
+ })
+ const secondChild = makeEntry(PANE_KEY_3, 3000, {
+ prompt: 'second child',
+ orchestration: {
+ taskId: 'task-2',
+ dispatchId: 'ctx-2',
+ parentTerminalHandle: 'term-parent',
+ parentPaneKey: PANE_KEY_2
+ }
+ })
+
+ const rows = buildWorktreeAgentRows({
+ tabs: [makeTab('tab-1'), makeTab('tab-2'), makeTab('tab-3')],
+ entries: [firstChild, parent, secondChild],
+ retained: [],
+ now: 4000
+ })
+ const ordered = applyAgentRowLineage(rows)
+
+ expect(ordered.map((row) => row.paneKey)).toEqual([PANE_KEY_2, PANE_KEY_1, PANE_KEY_3])
+ expect(ordered[0].lineage).toMatchObject({ depth: 0, childCount: 2 })
+ expect(ordered[1].lineage).toMatchObject({
+ depth: 1,
+ isFirstSibling: true,
+ isLastSibling: false
+ })
+ expect(ordered[2].lineage).toMatchObject({
+ depth: 1,
+ isFirstSibling: false,
+ isLastSibling: true
+ })
+ })
+
+ it('leaves orphan orchestration rows flat when the parent pane is not visible', () => {
+ const child = makeEntry(PANE_KEY_1, 1000, {
+ orchestration: {
+ taskId: 'task-1',
+ dispatchId: 'ctx-1',
+ parentTerminalHandle: 'term-missing',
+ parentPaneKey: PANE_KEY_2
+ }
+ })
+ const rows = buildWorktreeAgentRows({
+ tabs: [makeTab('tab-1')],
+ entries: [child],
+ retained: [],
+ now: 2000
+ })
+
+ expect(applyAgentRowLineage(rows)[0].lineage).toMatchObject({ depth: 0, childCount: 0 })
+ })
+
+ it('keeps nested dispatches under their nearest visible parent', () => {
+ const parent = makeEntry(PANE_KEY_1, 1000, { prompt: 'parent' })
+ const child = makeEntry(PANE_KEY_2, 2000, {
+ prompt: 'child',
+ orchestration: {
+ taskId: 'task-child',
+ dispatchId: 'ctx-child',
+ parentPaneKey: PANE_KEY_1
+ }
+ })
+ const grandchild = makeEntry(PANE_KEY_3, 3000, {
+ prompt: 'grandchild',
+ orchestration: {
+ taskId: 'task-grandchild',
+ dispatchId: 'ctx-grandchild',
+ parentPaneKey: PANE_KEY_2
+ }
+ })
+ const sibling = makeEntry(PANE_KEY_4, 4000, { prompt: 'sibling root' })
+ const rows = buildWorktreeAgentRows({
+ tabs: [makeTab('tab-1'), makeTab('tab-2'), makeTab('tab-3'), makeTab('tab-4')],
+ entries: [parent, child, grandchild, sibling],
+ retained: [],
+ now: 5000
+ })
+
+ const ordered = applyAgentRowLineage(rows)
+
+ expect(ordered.map((row) => row.paneKey)).toEqual([
+ PANE_KEY_1,
+ PANE_KEY_2,
+ PANE_KEY_3,
+ PANE_KEY_4
+ ])
+ expect(ordered[1].lineage).toMatchObject({ depth: 1, childCount: 1 })
+ expect(ordered[2].lineage).toMatchObject({ depth: 1, childCount: 0 })
+ })
+})
+
describe('selectMigrationUnsupportedEntriesForWorktree', () => {
it('returns raw migration records so shallow selectors can cache snapshots', () => {
const unsupported: MigrationUnsupportedPtyEntry = {
diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts
index ab3540d856e..0396bf1f929 100644
--- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts
+++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts
@@ -13,6 +13,7 @@ import {
} from '../../../../shared/agent-status-types'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
+import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage'
// Why: stable empty-array references so narrow selectors return the same
// reference when there's nothing for this worktree. Without stable empties,
@@ -324,12 +325,14 @@ export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] {
})
]
: liveEntries
- return buildWorktreeAgentRows({
- tabs: tabs ?? [],
- entries,
- retained,
- now
- })
+ return applyAgentRowLineage(
+ buildWorktreeAgentRows({
+ tabs: tabs ?? [],
+ entries,
+ retained,
+ now
+ })
+ )
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabs, liveEntries, migrationUnsupported, retained, agentStatusEpoch])
}
diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts
index 46221c63a00..e9fe252d62e 100644
--- a/src/renderer/src/hooks/useIpcEvents.ts
+++ b/src/renderer/src/hooks/useIpcEvents.ts
@@ -1977,7 +1977,10 @@ export function useIpcEvents(): void {
) {
return 'dropped'
}
- store.setAgentStatus(data.paneKey, payload, title, {
+ const statusPayload = data.orchestration
+ ? { ...payload, orchestration: data.orchestration }
+ : payload
+ store.setAgentStatus(data.paneKey, statusPayload, title, {
updatedAt: data.receivedAt,
stateStartedAt: data.stateStartedAt
})
diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts
index 01e1ea38e06..bc69374dace 100644
--- a/src/renderer/src/store/slices/agent-status.ts
+++ b/src/renderer/src/store/slices/agent-status.ts
@@ -6,6 +6,7 @@ import {
AGENT_STATE_HISTORY_MAX,
type AgentStateHistoryEntry,
type AgentStatusEntry,
+ type AgentStatusOrchestrationContext,
type AgentType,
type MigrationUnsupportedPtyEntry,
type ParsedAgentStatusPayload
@@ -54,7 +55,7 @@ export type AgentStatusSlice = {
/** Update or insert an agent status entry from a status payload. */
setAgentStatus: (
paneKey: string,
- payload: ParsedAgentStatusPayload,
+ payload: ParsedAgentStatusPayload & { orchestration?: AgentStatusOrchestrationContext },
terminalTitle?: string,
timing?: { updatedAt?: number; stateStartedAt?: number }
) => void
@@ -252,6 +253,10 @@ export const createAgentStatusSlice: StateCreator