refactor(native-chat): split the sidebar subagent rows out of this PR

The strip stands alone: the sidebar mapping, its observation plumbing and the
AgentStatusEntry.subagents wiring move to a stacked follow-up. No wire field
here is sidebar-only - the strip's rows read name, state, elapsed and tokens.
This commit is contained in:
Merge Sim
2026-09-09 15:54:27 -07:00
parent 1e5a2af8df
commit df0413ea8c
8 changed files with 29 additions and 282 deletions
@@ -6,7 +6,6 @@ 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'
@@ -220,116 +219,6 @@ 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' })]
})
])
// A summary without tasks ends the fan-out: children clear with it.
act(() => feed().emit({ type: 'status', session: summary({ status: 'idle', updatedAt: 3 }) }))
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,16 +1,7 @@
import { useEffect, useMemo, useSyncExternalStore } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume'
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 type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire'
import {
structuredAgentSessionPaneKey,
structuredAgentSessionStatusState
@@ -58,78 +49,17 @@ export function getStructuredAgentSessionTabs(
function useStructuredAgentSessionStatusSummary(
sessionId: string,
target: RuntimeClientTarget
): { summary: AgentSessionStatusSummary | null; observation: 'live' | 'unverifiable' } {
): AgentSessionStatusSummary | null {
const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target])
useEffect(() => feed.activate(), [feed])
const summary = useSyncExternalStore(
return useSyncExternalStore(
feed.subscribe,
() => feed.getSnapshot().get(sessionId) ?? null,
() => null
)
const observation = useSyncExternalStore(
feed.subscribe,
() => feed.getSessionObservation(sessionId),
() => 'unverifiable' as const
)
return { summary, observation }
}
/** 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':
case 'unverifiable':
return 'idle'
// 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 {
function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | null): 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.
@@ -139,7 +69,6 @@ function projectStatus(
}
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),
@@ -151,7 +80,6 @@ function projectStatus(
...(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]
@@ -164,8 +92,6 @@ function projectStatus(
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 &&
@@ -212,10 +138,10 @@ function StructuredAgentSessionStatusProjection({ tab }: { tab: StructuredTab })
() => getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }),
[environmentId]
)
const { summary, observation } = useStructuredAgentSessionStatusSummary(tab.entityId, target)
const summary = useStructuredAgentSessionStatusSummary(tab.entityId, target)
useEffect(() => {
projectStatus(tab, summary, observation)
}, [summary, observation, tab])
projectStatus(tab, summary)
}, [summary, tab])
useEffect(
() => () =>
useAppStore.getState().removeAgentStatus(structuredAgentSessionPaneKey(tab.id, tab.entityId)),
@@ -29,13 +29,8 @@ export function buildSubagentChildRows(args: {
return []
}
return subagents.map((subagent) => {
const observation = args.parentEntry.subagentObservation
const fresh = observation === 'live' || (observation === undefined && args.parentIsFresh)
const activeState = fresh && subagent.state !== 'idle' ? subagent.state : undefined
const state =
observation === 'unverifiable' && subagent.state !== 'idle'
? 'unverifiable'
: (activeState ?? 'idle')
const activeState = args.parentIsFresh && subagent.state !== 'idle' ? subagent.state : undefined
const state = activeState ?? 'idle'
const startedAt = subagent.startedAt > 0 ? subagent.startedAt : args.parentEntry.stateStartedAt
const paneKey = subagentRowKey(args.parentEntry.paneKey, subagent.id)
const entry: AgentStatusEntry = {
@@ -125,58 +125,6 @@ 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,7 +21,6 @@ export type StructuredAgentSessionStatusSnapshot = ReadonlyMap<string, AgentSess
export type StructuredAgentSessionStatusFeedOwner = {
activate: () => () => void
getSnapshot: () => StructuredAgentSessionStatusSnapshot
getSessionObservation: (sessionId: string) => 'live' | 'unverifiable'
subscribe: (listener: () => void) => () => void
}
@@ -38,7 +37,6 @@ 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
@@ -62,14 +60,12 @@ 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)
@@ -103,6 +99,15 @@ 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) {
@@ -117,19 +122,6 @@ 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,
@@ -138,19 +130,19 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed {
return
}
if (event.type === 'end') {
loseConnection(candidate)
fenceCandidateAndReconnect(candidate)
return
}
applyEvent(event)
},
() => {
if (active(candidate)) {
loseConnection(candidate)
fenceCandidateAndReconnect(candidate)
}
},
() => {
if (active(candidate)) {
loseConnection(candidate)
fenceCandidateAndReconnect(candidate)
}
}
)
@@ -161,7 +153,13 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed {
opened.unsubscribe()
}
})
.catch(() => loseConnection(candidate))
.catch(() => {
if (active(candidate)) {
fenceCandidateAndReconnect(candidate)
} else {
scheduleReconnect(candidate)
}
})
}
open = (): void => {
const candidate = ++generation
@@ -188,7 +186,7 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed {
}
console.warn('[structured-session-status] host too old for the status feed', environmentId)
})
.catch(() => loseConnection(candidate))
.catch(() => scheduleReconnect(candidate))
}
const stop = (): void => {
generation += 1
@@ -196,9 +194,6 @@ 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 {
@@ -216,8 +211,6 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed {
}
},
getSnapshot: () => snapshot,
getSessionObservation: (sessionId) =>
confirmedSessions.has(sessionId) ? 'live' : 'unverifiable',
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
@@ -83,7 +83,6 @@ export type AgentLaunchConfigRegistryEntry = {
}
export type AgentStatusPayload = ParsedAgentStatusPayload & {
subagentObservation?: AgentStatusEntry['subagentObservation']
orchestration?: AgentStatusOrchestrationContext
promptInteractionKey?: string
restoredUnconfirmed?: boolean
@@ -256,7 +256,6 @@ 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
@@ -102,8 +102,6 @@ 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