feat(sidebar): show native-chat subagents as sidebar child rows, like CLI agents already do (#19807)

* feat(sidebar): indent native-chat subagents under their session row

Stacked on #19311, which adds the background-task channel this reads. The
bridge maps agent-kind background tasks into AgentStatusEntry.subagents, and
the renderer status feed confirms per connection so a reconnect cannot leave a
child asserting live from a stream that ended.

* fix(sidebar): avoid completed age for unverifiable subagents

* fix(sidebar): preserve unverifiable child verdicts

---------

Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
Brennan Benson
2026-09-10 12:29:30 -07:00
committed by GitHub
co-authored by Merge Sim
parent f2af92b2fa
commit 4408fe897a
13 changed files with 338 additions and 35 deletions
@@ -99,6 +99,23 @@ describe('patchDashboardSnapshotFromAgentStatus', () => {
})
})
it('keeps an unverifiable child compatible with the pop-out wire vocabulary', () => {
const result = patchDashboardSnapshotFromAgentStatus(
snapshot(),
event({
subagents: [{ id: 'child-1', state: 'unverifiable', startedAt: 100 }]
})
)
expect(result.snapshot.cards[0].subagents).toEqual([
{
id: 'tab-1:leaf-1\u0000subagent:child-1',
name: 'unknown',
dotState: 'idle'
}
])
})
it('ignores stale, wrong-workspace, and session-only events', () => {
const original = snapshot()
expect(
@@ -6,6 +6,7 @@ import {
type DashboardSnapshot
} from '../../../../shared/dashboard-snapshot'
import { dashboardBucketForDotState } from '../dashboard/dashboard-card-bucket'
import { dashboardCardDotState } from '../dashboard/dashboard-row-bucket'
export type DashboardAgentStatusPatchResult = {
matched: boolean
@@ -22,7 +23,7 @@ function patchedSubagents(
return event.subagents.map((subagent) => ({
id: `${card.paneKey}\u0000subagent:${subagent.id}`,
name: subagent.description || subagent.agentType || 'unknown',
dotState: subagent.state
dotState: dashboardCardDotState(subagent.state)
}))
}
@@ -52,3 +52,11 @@ describe('lastEnteredDoneAt shares the Smart Sort completion clock', () => {
expect(agentEntryCompletionAt(entry)).toBeNull()
})
})
describe('lastEnteredDoneAt subagent rows', () => {
it('does not report a synthetic completion when the child is unverifiable', () => {
const entry = doneEntry()
expect(lastEnteredDoneAt({ rowSource: 'subagent', state: 'unverifiable', entry })).toBeNull()
})
})
@@ -10,9 +10,8 @@ import type { DashboardAgentRow } from './useDashboardData'
export function lastEnteredDoneAt(
agent: Pick<DashboardAgentRow, 'rowSource' | 'state' | 'entry'>
): number | null {
// Why: idle subagent child rows are alive-but-idle (teammates persist
// between turns), not finished — fall through to the started-at timestamp.
if (agent.rowSource === 'subagent' && agent.state === 'idle') {
// Why: a subagent's synthetic entry may say done while its row is idle or unverifiable.
if (agent.rowSource === 'subagent' && agent.state !== 'done') {
return null
}
const entry = agent.entry
@@ -6,6 +6,7 @@ import type {
AgentSessionStatusEvent,
AgentSessionStatusSummary
} from '../../../../shared/agent-session-wire'
import { buildSubagentChildRows } from '../sidebar/worktree-subagent-child-rows'
import { resolveAttention } from '../sidebar/smart-attention'
import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
@@ -219,6 +220,133 @@ describe('StructuredAgentSessionStatusBridge', () => {
expect(statuses()).toEqual([expect.objectContaining({ state: 'blocked' })])
})
it('publishes agent-kind background tasks as the sidebar subagent children', async () => {
render(<StructuredAgentSessionStatusBridge />)
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
act(() =>
feed().emit({
type: 'snapshot',
sessions: [
summary({
backgroundTasks: [
{
id: 'child-1',
kind: 'agent',
name: 'deep_review',
description: 'Review the diff',
state: 'working',
startedAt: 500
},
// A backgrounded shell is not a subagent; kinds stay distinct.
{ id: 'shell-1', kind: 'command', description: 'sleep 180', state: 'working' }
]
})
]
})
)
expect(statuses()).toEqual([
expect.objectContaining({
subagents: [
{
id: 'child-1',
state: 'working',
startedAt: 500,
agentType: 'deep_review',
description: 'Review the diff'
}
]
})
])
// An unchanged roster must not rewrite the store.
const writes = mocks.setAgentStatus.mock.calls.length
act(() =>
feed().emit({
type: 'status',
session: summary({
backgroundTasks: [
{
id: 'child-1',
kind: 'agent',
name: 'deep_review',
description: 'Review the diff',
state: 'working',
startedAt: 500
},
{ id: 'shell-1', kind: 'command', description: 'sleep 180', state: 'working' }
]
})
})
)
expect(mocks.setAgentStatus.mock.calls.length).toBe(writes)
act(() =>
feed().emit({
type: 'status',
session: summary({
updatedAt: 2,
backgroundTasks: [
{ id: 'child-1', kind: 'agent', name: 'deep_review', state: 'waiting', startedAt: 500 }
]
})
})
)
expect(statuses()).toEqual([
expect.objectContaining({
subagents: [expect.objectContaining({ id: 'child-1', state: 'waiting' })]
})
])
act(() =>
feed().emit({
type: 'status',
session: summary({
updatedAt: 3,
backgroundTasks: [{ id: 'child-1', kind: 'agent', state: 'unverifiable' }]
})
})
)
expect(
buildSubagentChildRows({
parentEntry: statuses()[0],
tab: structuredTab as never,
parentIsFresh: true
})[0]?.state
).toBe('unverifiable')
// A summary without tasks ends the fan-out: children clear with it.
act(() => feed().emit({ type: 'status', session: summary({ status: 'idle', updatedAt: 4 }) }))
expect(statuses()).toEqual([expect.objectContaining({ subagents: undefined })])
})
it('keeps quiet live children authoritative and reconfirms them per session after reconnect', async () => {
render(<StructuredAgentSessionStatusBridge />)
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
const live = summary({ backgroundTasks: [{ id: 'child', kind: 'agent', state: 'working' }] })
const childState = () =>
buildSubagentChildRows({
parentEntry: statuses()[0],
tab: structuredTab as never,
parentIsFresh: false
})[0]?.state
act(() => feed().emit({ type: 'snapshot', sessions: [live] }))
// A hook's evidence window has expired, but the host has not retracted its live task.
expect(childState()).toBe('working')
act(() => feed().emit({ type: 'end' }))
expect(childState()).toBe('unverifiable')
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledTimes(2))
act(() => feed(1).emit({ type: 'snapshot', sessions: [] }))
expect(childState()).toBe('unverifiable')
act(() => feed(1).emit({ type: 'status', session: live }))
expect(childState()).toBe('working')
const writes = mocks.setAgentStatus.mock.calls.length
act(() => feed(1).emit({ type: 'status', session: live }))
expect(mocks.setAgentStatus).toHaveBeenCalledTimes(writes)
act(() => feed(1).emit({ type: 'status', session: summary({ backgroundTasks: [] }) }))
expect(childState()).toBeUndefined()
})
it('carries the model, the running tool line, and the last assistant message', async () => {
render(<StructuredAgentSessionStatusBridge />)
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
@@ -1,7 +1,16 @@
import { useEffect, useMemo, useSyncExternalStore } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume'
import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire'
import type {
AgentSessionBackgroundTask,
AgentSessionStatusSummary
} from '../../../../shared/agent-session-wire'
import {
AGENT_STATUS_MAX_SUBAGENTS,
agentSubagentsEqual,
type AgentSubagentSnapshot,
type AgentSubagentState
} from '../../../../shared/agent-status-types'
import {
structuredAgentSessionPaneKey,
structuredAgentSessionStatusState
@@ -49,17 +58,79 @@ export function getStructuredAgentSessionTabs(
function useStructuredAgentSessionStatusSummary(
sessionId: string,
target: RuntimeClientTarget
): AgentSessionStatusSummary | null {
): { summary: AgentSessionStatusSummary | null; observation: 'live' | 'unverifiable' } {
const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target])
useEffect(() => feed.activate(), [feed])
return useSyncExternalStore(
const summary = useSyncExternalStore(
feed.subscribe,
() => feed.getSnapshot().get(sessionId) ?? null,
() => null
)
const observation = useSyncExternalStore(
feed.subscribe,
() => feed.getSessionObservation(sessionId),
() => 'unverifiable' as const
)
return { summary, observation }
}
function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | null): void {
/** Matches the wire-parse bound in `normalizeSubagentSnapshot`. */
const SUBAGENT_ID_MAX_LENGTH = 64
function subagentStateFromTask(task: AgentSessionBackgroundTask): AgentSubagentState {
switch (task.state) {
case 'waiting':
return 'waiting'
case 'blocked':
return 'blocked'
case 'done':
case 'idle':
return 'idle'
case 'unverifiable':
return 'unverifiable'
// Absent state is an old host's live task; live means working here.
case 'working':
case 'monitoring':
case undefined:
return 'working'
}
}
/** Sidebar children for a structured session: the agent-kind background tasks
* the host publishes, mapped to the sidebar's own subagent vocabulary rather
* than widening it. Kinds stay distinct — a backgrounded shell never counts
* as a subagent. */
function subagentSnapshotsFromTasks(
tasks: AgentSessionBackgroundTask[] | undefined
): AgentSubagentSnapshot[] | undefined {
if (!tasks) {
return undefined
}
const snapshots: AgentSubagentSnapshot[] = []
for (const task of tasks) {
const id = task.id.trim()
if (task.kind !== 'agent' || id.length === 0 || id.length > SUBAGENT_ID_MAX_LENGTH) {
continue
}
snapshots.push({
id,
state: subagentStateFromTask(task),
startedAt: task.startedAt ?? 0,
...(task.name ? { agentType: task.name } : {}),
...(task.description ? { description: task.description } : {})
})
if (snapshots.length >= AGENT_STATUS_MAX_SUBAGENTS) {
break
}
}
return snapshots.length > 0 ? snapshots : undefined
}
function projectStatus(
tab: StructuredTab,
summary: AgentSessionStatusSummary | null,
observation: 'live' | 'unverifiable'
): void {
const paneKey = structuredAgentSessionPaneKey(tab.id, tab.entityId)
const store = useAppStore.getState()
// No persisted turn yet (or nothing known): the row shows no agent status at all.
@@ -69,6 +140,7 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary |
}
return
}
const subagents = subagentSnapshotsFromTasks(summary.backgroundTasks)
const desired = {
// Shared with `worktree ps`, so the CLI and this row cannot disagree about one session.
state: structuredAgentSessionStatusState(summary.status),
@@ -80,6 +152,7 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary |
...(summary.toolName ? { toolName: summary.toolName } : {}),
...(summary.toolInput ? { toolInput: summary.toolInput } : {}),
...(summary.lastAssistantMessage ? { lastAssistantMessage: summary.lastAssistantMessage } : {}),
...(subagents ? { subagents, subagentObservation: observation } : {}),
sessionBoundary: false
} as const
const current = store.agentStatusByPaneKey?.[paneKey]
@@ -92,6 +165,8 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary |
current.toolName === summary.toolName &&
current.toolInput === summary.toolInput &&
current.lastAssistantMessage === summary.lastAssistantMessage &&
agentSubagentsEqual(current.subagents, subagents) &&
current.subagentObservation === desired.subagentObservation &&
current.sessionBoundary === desired.sessionBoundary &&
current.updatedAt === summary.updatedAt &&
current.terminalTitle === tab.label &&
@@ -138,10 +213,10 @@ function StructuredAgentSessionStatusProjection({ tab }: { tab: StructuredTab })
() => getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }),
[environmentId]
)
const summary = useStructuredAgentSessionStatusSummary(tab.entityId, target)
const { summary, observation } = useStructuredAgentSessionStatusSummary(tab.entityId, target)
useEffect(() => {
projectStatus(tab, summary)
}, [summary, tab])
projectStatus(tab, summary, observation)
}, [summary, observation, tab])
useEffect(
() => () =>
useAppStore.getState().removeAgentStatus(structuredAgentSessionPaneKey(tab.id, tab.entityId)),
@@ -29,8 +29,17 @@ export function buildSubagentChildRows(args: {
return []
}
return subagents.map((subagent) => {
const activeState = args.parentIsFresh && subagent.state !== 'idle' ? subagent.state : undefined
const state = activeState ?? 'idle'
const observation = args.parentEntry.subagentObservation
const fresh = observation === 'live' || (observation === undefined && args.parentIsFresh)
const activeState =
fresh && subagent.state !== 'idle' && subagent.state !== 'unverifiable'
? subagent.state
: undefined
const state =
subagent.state === 'unverifiable' ||
(observation === 'unverifiable' && subagent.state !== 'idle')
? 'unverifiable'
: (activeState ?? 'idle')
const startedAt = subagent.startedAt > 0 ? subagent.startedAt : args.parentEntry.stateStartedAt
const paneKey = subagentRowKey(args.parentEntry.paneKey, subagent.id)
const entry: AgentStatusEntry = {
@@ -125,6 +125,58 @@ describe('structured agent session status feed', () => {
expect(feed.getSnapshot().get('session-2')?.status).toBe('idle')
})
it('confirms sessions per connection and rejects late events from a disconnected stream', async () => {
const feed = getStructuredAgentSessionStatusFeed(LOCAL)
const deactivate = feed.activate()
await vi.advanceTimersByTimeAsync(0)
hostEmit()({ type: 'snapshot', sessions: [summary('one'), summary('two')] })
expect(feed.getSessionObservation('one')).toBe('live')
await vi.advanceTimersByTimeAsync(31 * 60_000)
expect(feed.getSessionObservation('one')).toBe('live')
expect(mocks.subscribeStatus).toHaveBeenCalledOnce()
hostEmit()({ type: 'end' })
expect(feed.getSessionObservation('one')).toBe('unverifiable')
hostEmit()({ type: 'status', session: summary('one') })
expect(feed.getSessionObservation('one')).toBe('unverifiable')
await vi.advanceTimersByTimeAsync(300)
hostEmit(1)({ type: 'snapshot', sessions: [summary('two')] })
expect(feed.getSessionObservation('two')).toBe('live')
expect(feed.getSessionObservation('one')).toBe('unverifiable')
expect(feed.getSnapshot().has('one')).toBe(true)
hostEmit(1)({ type: 'status', session: summary('one') })
expect(feed.getSessionObservation('one')).toBe('live')
deactivate()
expect(feed.getSessionObservation('one')).toBe('unverifiable')
expect(vi.getTimerCount()).toBe(0)
})
it.each(['error', 'close', 'reject'] as const)(
'revokes confirmation on subscription %s',
async (failure) => {
let reject!: (error: Error) => void
mocks.subscribeStatus.mockImplementationOnce(
() =>
new Promise((_resolve, rejectPromise) => {
reject = rejectPromise
})
)
const feed = getStructuredAgentSessionStatusFeed(LOCAL)
feed.activate()
hostEmit()({ type: 'status', session: summary('one') })
expect(feed.getSessionObservation('one')).toBe('live')
if (failure === 'reject') {
reject(new Error('disconnected'))
} else {
mocks.subscribeStatus.mock.calls[0][failure === 'error' ? 2 : 3]()
}
await vi.advanceTimersByTimeAsync(0)
expect(feed.getSessionObservation('one')).toBe('unverifiable')
hostEmit()({ type: 'status', session: summary('one') })
expect(feed.getSessionObservation('one')).toBe('unverifiable')
expect(vi.getTimerCount()).toBe(1)
}
)
it('stops a pending reconnect when the feeds are reset between tests', async () => {
getStructuredAgentSessionStatusFeed(LOCAL).activate()
await vi.advanceTimersByTimeAsync(0)
@@ -21,6 +21,7 @@ export type StructuredAgentSessionStatusSnapshot = ReadonlyMap<string, AgentSess
export type StructuredAgentSessionStatusFeedOwner = {
activate: () => () => void
getSnapshot: () => StructuredAgentSessionStatusSnapshot
getSessionObservation: (sessionId: string) => 'live' | 'unverifiable'
subscribe: (listener: () => void) => () => void
}
@@ -37,6 +38,7 @@ export function structuredAgentSessionStatusFeedKey(target: RuntimeClientTarget)
function createOwner(target: RuntimeClientTarget): OwnedStatusFeed {
let snapshot: StructuredAgentSessionStatusSnapshot = new Map()
const confirmedSessions = new Set<string>()
const listeners = new Set<() => void>()
const activations = new Set<symbol>()
let generation = 0
@@ -60,12 +62,14 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed {
// the first snapshot can be empty and dropping those rows flickers every one to no-status.
const next = new Map(snapshot)
for (const session of event.sessions) {
confirmedSessions.add(session.sessionId)
next.set(session.sessionId, session)
}
setSnapshot(next)
return
}
if (event.type === 'status') {
confirmedSessions.add(event.session.sessionId)
const next = new Map(snapshot)
next.set(event.session.sessionId, event.session)
setSnapshot(next)
@@ -99,15 +103,6 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed {
emit()
}
}
const fenceCandidateAndReconnect = (candidate: number): void => {
if (candidate !== generation) {
return
}
generation += 1
revokeSnapshotOwnership()
dropHandle()
scheduleReconnect(generation)
}
let open = (): void => {}
const scheduleReconnect = (candidate: number): void => {
if (!active(candidate) || reconnectTimer) {
@@ -122,6 +117,19 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed {
}
}, delay)
}
// Losing contact is never exit: the sessions go unverifiable and this client stops
// claiming host-owned execution, but nothing here settles them.
const loseConnection = (candidate: number): void => {
if (candidate !== generation) {
return
}
generation += 1
confirmedSessions.clear()
revokeSnapshotOwnership()
emit()
dropHandle()
scheduleReconnect(generation)
}
const subscribeToHost = (candidate: number): void => {
void subscribeStructuredAgentSessionStatus(
target,
@@ -130,19 +138,19 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed {
return
}
if (event.type === 'end') {
fenceCandidateAndReconnect(candidate)
loseConnection(candidate)
return
}
applyEvent(event)
},
() => {
if (active(candidate)) {
fenceCandidateAndReconnect(candidate)
loseConnection(candidate)
}
},
() => {
if (active(candidate)) {
fenceCandidateAndReconnect(candidate)
loseConnection(candidate)
}
}
)
@@ -153,13 +161,7 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed {
opened.unsubscribe()
}
})
.catch(() => {
if (active(candidate)) {
fenceCandidateAndReconnect(candidate)
} else {
scheduleReconnect(candidate)
}
})
.catch(() => loseConnection(candidate))
}
open = (): void => {
const candidate = ++generation
@@ -186,7 +188,7 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed {
}
console.warn('[structured-session-status] host too old for the status feed', environmentId)
})
.catch(() => scheduleReconnect(candidate))
.catch(() => loseConnection(candidate))
}
const stop = (): void => {
generation += 1
@@ -194,6 +196,9 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed {
dropHandle()
revokeSnapshotOwnership()
reconnectAttempt = 0
// Teardown only runs once nothing is activated, so re-confirmation is the next
// subscribe's job and there is no mounted reader left to notify.
confirmedSessions.clear()
}
return {
@@ -211,6 +216,8 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed {
}
},
getSnapshot: () => snapshot,
getSessionObservation: (sessionId) =>
confirmedSessions.has(sessionId) ? 'live' : 'unverifiable',
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
@@ -83,6 +83,7 @@ export type AgentLaunchConfigRegistryEntry = {
}
export type AgentStatusPayload = ParsedAgentStatusPayload & {
subagentObservation?: AgentStatusEntry['subagentObservation']
orchestration?: AgentStatusOrchestrationContext
promptInteractionKey?: string
restoredUnconfirmed?: boolean
@@ -256,6 +256,7 @@ export function buildAgentStatusLiveEntry(
lastAssistantMessageIsToolOutput: payload.lastAssistantMessageIsToolOutput,
...(lastCompletedAssistantMessage ? { lastCompletedAssistantMessage } : {}),
orchestration,
...(payload.subagentObservation ? { subagentObservation: payload.subagentObservation } : {}),
subagents: agentSubagentsEqual(existing?.subagents, payload.subagents)
? existing?.subagents
: payload.subagents,
+2
View File
@@ -546,6 +546,7 @@ Fix dispatch fallback preview for normalized status prompts`
subagents: [
{ id: 'a1', state: 'working', startedAt: 100, agentType: 'general-purpose' },
{ id: 'r1', state: 'idle', startedAt: 'nope', description: 'line\none' },
{ id: 'u1', state: 'unverifiable', startedAt: 200 },
{ id: '', state: 'working', startedAt: 1 },
{ id: 'bad-state', state: 'running', startedAt: 1 },
'garbage',
@@ -572,6 +573,7 @@ Fix dispatch fallback preview for normalized status prompts`
startedAt: 0,
description: 'line one'
})
expect(result?.subagents?.[2]).toMatchObject({ id: 'u1', state: 'unverifiable' })
})
it('omits subagents when absent or empty', () => {
+5 -2
View File
@@ -85,7 +85,7 @@ export type AgentStatusOrchestrationContext = {
attention?: OrchestrationFleetAttention
}
export type AgentSubagentState = 'working' | 'blocked' | 'waiting' | 'idle'
export type AgentSubagentState = 'working' | 'blocked' | 'waiting' | 'idle' | 'unverifiable'
/** A live in-process child of the pane's provider session. Rendered as an
* indented child row with no PTY of its own. */
@@ -102,6 +102,8 @@ export type AgentSubagentSnapshot = {
}
export type AgentStatusEntry = {
/** Renderer-local status-feed confirmation for children; absent on hook rows. */
subagentObservation?: 'live' | 'unverifiable'
state: AgentStatusState
/** Ongoing work that does not require foreground agent execution. Only valid while working. */
workingMode?: AgentWorkingMode
@@ -302,7 +304,8 @@ function normalizeSubagentSnapshot(value: unknown): AgentSubagentSnapshot | null
obj.state !== 'working' &&
obj.state !== 'blocked' &&
obj.state !== 'waiting' &&
obj.state !== 'idle'
obj.state !== 'idle' &&
obj.state !== 'unverifiable'
) {
return null
}