fix(web): synchronize subagent navigation state

This commit is contained in:
imccyu
2026-08-02 01:38:28 +08:00
committed by Tianyi Cui
parent b94d2f9c1d
commit 23680e838b
25 changed files with 462 additions and 106 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: e4b8e1777ad3a01144dfe9da0a81f83e0f4e2b6a
README.zh.md: 5aa53ce1ec680579b44f0b2859104a1eda7471d3
README.md: f956be22384a42e9ed30e8aa5f25fe8173cc9f9c
README.zh.md: 49449c51d89d957b5bd39798c9167607f72a5c3a

View File

@@ -56,7 +56,7 @@ Each resident `Session` owns a `modelSelection` snapshot containing the current
## Addressed subagent conversations
`SessionListState.subagentsByParent` carries direct durable catalogs and `currentAddress` records the catalog-derived `{parentSessionId, childSessionId}` for the selected child. Only that recorded address selects subagent transport: lineage alone remains insufficient because ordinary forks also have `parentId`. An addressed Session loads and reconnects through `subagent.history`, sends through `subagent.prompt`, never calls ordinary cancel, and persists its address with the selected session across refresh and repeated ordinary selection of that same child. The list also projects the header's coarse `origin: 'subagent'` classification for navigation filtering; the recorded address, not `origin`, remains transport authority. Catalog reads are single-flight; `host/session-status` flips a listed child's coarse activity in place, while an origin-classified `host/session-added` immediately marks any loaded direct parent row `hasChildren: true` and causes one debounced refetch when that parent is selected or its catalog is open. Parent availability propagates into `ConversationSnapshot.subagent` so presentation can replace the composer with a read-only explanation without activating the parent.
`SessionListState.subagentsByParent` carries direct durable catalogs and `currentAddress` records the catalog-derived `{parentSessionId, childSessionId}` for the selected child. Only that recorded address selects subagent transport: lineage alone remains insufficient because ordinary forks also have `parentId`. An addressed Session loads and reconnects through `subagent.history`, sends through `subagent.prompt`, never calls ordinary cancel, and persists its address with the selected session across refresh and repeated ordinary selection of that same child. The list also projects the header's coarse `origin: 'subagent'` classification for navigation filtering; the recorded address, not `origin`, remains transport authority. Catalog reads are single-flight; the Host baseline and `host/session-status` both derive activity from child Agent driver status, and status frames received during a read are replayed over its response. An origin-classified `host/session-added` immediately marks any loaded direct parent row `hasChildren: true` and causes one debounced refetch when that parent is selected or its catalog is open. Parent availability propagates into `ConversationSnapshot.subagent` so presentation can replace the composer with a read-only explanation without activating the parent.
## Model Experience

View File

@@ -56,7 +56,7 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验
## 已寻址的 subagent 对话
`SessionListState.subagentsByParent` 携带直接持久化目录,`currentAddress` 则记录所选 child 从目录得到的 `{parentSessionId, childSessionId}`。只有这份已记录地址能选择 subagent 传输;单凭谱系仍然不足,因为普通 fork 同样具有 `parentId`。已寻址的 Session 通过 `subagent.history` 加载和重连,通过 `subagent.prompt` 发送,绝不调用普通取消,并在刷新期间及通过普通选择路径重复选择同一 child 时,把地址与所选会话一同持久化。列表还会投影 header 的粗粒度 `origin: 'subagent'` 分类供导航过滤;传输的权威依据仍是已记录地址,而不是 `origin`。目录读取为 single-flight`host/session-status` 就地翻转已列 child 的粗粒度活动状态,按 origin 分类的 `host/session-added` 会立即把任何已加载的直接 parent 行标记为 `hasChildren: true`,并在该 parent 被选中或其目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。
`SessionListState.subagentsByParent` 携带直接持久化目录,`currentAddress` 则记录所选 child 从目录得到的 `{parentSessionId, childSessionId}`。只有这份已记录地址能选择 subagent 传输;单凭谱系仍然不足,因为普通 fork 同样具有 `parentId`。已寻址的 Session 通过 `subagent.history` 加载和重连,通过 `subagent.prompt` 发送,绝不调用普通取消,并在刷新期间及通过普通选择路径重复选择同一 child 时,把地址与所选会话一同持久化。列表还会投影 header 的粗粒度 `origin: 'subagent'` 分类供导航过滤;传输的权威依据仍是已记录地址,而不是 `origin`。目录读取为 single-flightHost 基线与 `host/session-status` 都根据 child Agent driver 状态推导活动状态,读取期间收到的状态帧会在该读取的响应之上回放。按 origin 分类的 `host/session-added` 会立即把任何已加载的直接 parent 行标记为 `hasChildren: true`,并在该 parent 被选中或其目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。
## 模型体验

View File

@@ -58,6 +58,7 @@ export interface SubagentCatalogSnapshot extends SubagentCatalog {
interface CatalogInflight {
readonly promise: Promise<void>
readonly expandableRows: Set<SessionId>
readonly activityRows: Map<SessionId, 'running' | 'inactive'>
}
type SessionListMutation =
@@ -288,6 +289,7 @@ export class SessionManager {
if (existing !== undefined) return existing.promise
const previous = this.catalogs.get(parentSessionId)
const expandableRows = new Set<SessionId>()
const activityRows = new Map<SessionId, 'running' | 'inactive'>()
this.catalogs.set(parentSessionId, {
entries: previous?.entries ?? [],
parentAvailable: previous?.parentAvailable ?? false,
@@ -301,7 +303,7 @@ export class SessionManager {
if (result.ok) {
this.catalogs.set(parentSessionId, {
...result.value,
entries: this.withExpandableRows(result.value.entries, expandableRows),
entries: this.withCatalogMutations(result.value.entries, expandableRows, activityRows),
state: 'ready',
error: null,
})
@@ -311,7 +313,9 @@ export class SessionManager {
}
} else {
this.catalogs.set(parentSessionId, {
entries: this.withExpandableRows(previous?.entries ?? [], expandableRows),
entries: this.withCatalogMutations(
previous?.entries ?? [], expandableRows, activityRows,
),
parentAvailable: previous?.parentAvailable ?? false,
state: 'error',
error: result.error,
@@ -320,7 +324,9 @@ export class SessionManager {
} catch (error: unknown) {
const folded = transportError<never>(error)
this.catalogs.set(parentSessionId, {
entries: this.withExpandableRows(previous?.entries ?? [], expandableRows),
entries: this.withCatalogMutations(
previous?.entries ?? [], expandableRows, activityRows,
),
parentAvailable: previous?.parentAvailable ?? false,
state: 'error',
error: folded.ok ? null : folded.error,
@@ -330,7 +336,7 @@ export class SessionManager {
this.notifier.markDirty()
}
})()
this.catalogInflight.set(parentSessionId, { promise: operation, expandableRows })
this.catalogInflight.set(parentSessionId, { promise: operation, expandableRows, activityRows })
return operation
}
@@ -651,19 +657,22 @@ export class SessionManager {
return
}
case 'host/session-removed': {
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
if (this.addresses.has(frame.sessionId)) {
const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId)
const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId)
this.recordMutation(durableSubagent
? { kind: 'status', sessionId: frame.sessionId, running: false }
: { kind: 'remove', sessionId: frame.sessionId })
this.updateCatalogActivity(frame.sessionId, false)
if (durableSubagent) {
// An Activation detaching is not durable child deletion:
// keep the addressed conversation usable and return its catalog row
// to the inactive state.
// keep its lineage and conversation while returning it to idle.
this.sessions.get(frame.sessionId)?.handleRunning(false)
this.updateCatalogActivity(frame.sessionId, false)
} else {
this.sessions.get(frame.sessionId)?.handleRemoved()
}
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
return
}
case 'host/session-status': {
@@ -725,11 +734,14 @@ export class SessionManager {
this.catalogDebounce.set(parentSessionId, timer)
}
/** Flip a listed child's coarse activity in place from the shared Host frame. */
/** Apply one Agent-driver transition to loaded and in-flight catalogs. */
private updateCatalogActivity(childSessionId: SessionId, running: boolean): void {
const activity = running ? 'running' as const : 'inactive' as const
for (const inflight of this.catalogInflight.values()) {
inflight.activityRows.set(childSessionId, activity)
}
let changed = false
for (const [parentSessionId, catalog] of this.catalogs) {
const activity = running ? 'running' as const : 'inactive' as const
if (!catalog.entries.some(entry =>
entry.kind === 'child' && entry.id === childSessionId && entry.activity !== activity)) continue
const entries = catalog.entries.map((entry) => {
@@ -764,14 +776,22 @@ export class SessionManager {
if (changed) this.notifier.markDirty()
}
/** Fold request-local positive row mutations into one catalog result before publication. */
private withExpandableRows(
/** Fold request-local row mutations into one catalog result before publication. */
private withCatalogMutations(
entries: SubagentCatalog['entries'],
expandableRows: ReadonlySet<SessionId>,
activityRows: ReadonlyMap<SessionId, 'running' | 'inactive'>,
): SubagentCatalog['entries'] {
return entries.map(entry => entry.kind === 'child' && expandableRows.has(entry.id)
? { ...entry, hasChildren: true }
: entry)
return entries.map((entry) => {
if (entry.kind !== 'child') return entry
const activity = activityRows.get(entry.id)
if (!expandableRows.has(entry.id) && activity === undefined) return entry
return {
...entry,
...expandableRows.has(entry.id) ? { hasChildren: true } : {},
...activity === undefined ? {} : { activity },
}
})
}
private buildListSnapshot(): SessionListSnapshot {

View File

@@ -625,13 +625,15 @@ export class SessionsService implements ISessions {
while (address !== undefined && !seen.has(address.childSessionId)) {
const childId = address.childSessionId
seen.add(childId)
if (byId[childId] === undefined) {
const child = subagentsByParent[address.parentSessionId]?.entries
.find(entry => entry.kind === 'child' && entry.id === childId)
if (child?.kind !== 'child') break
const child = subagentsByParent[address.parentSessionId]?.entries
.find(entry => entry.kind === 'child' && entry.id === childId)
if (child?.kind !== 'child') break
const displayTitle = child.label ?? childId
const summary = byId[childId]
if (summary === undefined) {
byId[childId] = {
id: childId,
displayTitle: child.label ?? childId,
displayTitle,
parentId: address.parentSessionId,
origin: 'subagent',
running: child.activity === 'running',
@@ -639,8 +641,11 @@ export class SessionsService implements ISessions {
blank: false,
updatedAt: 0,
}
} else if (summary.displayTitle !== displayTitle) {
byId[childId] = { ...summary, displayTitle }
}
if (byId[address.parentSessionId] !== undefined) break
const parent = byId[address.parentSessionId]
if (parent !== undefined && parent.origin !== 'subagent') break
address = this.manager.navigationAddress(address.parentSessionId)
}
}

View File

@@ -341,6 +341,9 @@ describe('subagent catalogs', () => {
rpcId: 'child-detached' as never,
payload: { type: 'host/session-removed', sessionId: S2 },
})
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({
origin: 'subagent', parentSessionId: S1, running: false,
})
expect(manager.get(S2).getSnapshot()).toMatchObject({
removed: false,
subagent: {
@@ -467,6 +470,65 @@ describe('subagent catalogs', () => {
{ kind: 'child', id: S1, hasChildren: false },
])
})
it('replays status frames over an older in-flight catalog response', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => response.promise
const manager = new SessionManager(api)
const refresh = manager.refreshSubagents(root)
manager.handleHostEnvelope({
rpcId: 'child-stopped' as never,
payload: { type: 'host/session-status', sessionId: S1, running: false },
})
manager.handleHostEnvelope({
rpcId: 'child-started' as never,
payload: { type: 'host/session-status', sessionId: S2, running: true },
})
response.resolve(ok({
entries: [
{
kind: 'child', id: S1, mode: 'continuable', label: 'stopped',
activity: 'running', hasChildren: false,
},
{
kind: 'child', id: S2, mode: 'continuable', label: 'started',
activity: 'inactive', hasChildren: false,
},
] as never[],
parentAvailable: true,
}))
await refresh
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, activity: 'inactive' },
{ kind: 'child', id: S2, activity: 'running' },
])
})
it('marks a detached catalog child inactive without requiring a selected address', async () => {
const api = new FakeApiClient()
api.onSubagentList = () => Promise.resolve(ok({
entries: [{
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
const manager = new SessionManager(api)
await manager.refreshSubagents(S1)
manager.handleHostEnvelope({
rpcId: 'child-detached' as never,
payload: { type: 'host/session-removed', sessionId: S2 },
})
expect(manager.getListSnapshot().subagentsByParent[S1]?.entries).toMatchObject([
{ kind: 'child', id: S2, activity: 'inactive' },
])
})
})
describe('remaining branches', () => {

View File

@@ -362,6 +362,45 @@ describe('slot-store scope prune hook', () => {
})
describe('catalog-addressed navigation', () => {
it('uses catalog labels for a listed addressed route', async () => {
const b = bench()
b.api.onSubagentList = (payload) => {
const { parentSessionId } = payload as { parentSessionId: SessionId }
if (parentSessionId === sid('root')) {
return Promise.resolve(ok({
entries: [{
kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
activity: 'inactive', hasChildren: true,
}] as never[],
parentAvailable: true,
}))
}
if (parentSessionId === sid('child')) {
return Promise.resolve(ok({
entries: [{
kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: false,
}))
}
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
}
await feedList(b, [
{ id: 'root' },
{ id: 'child', cwd: '/summary-child', parentId: 'root', origin: 'subagent' },
{ id: 'grandchild', cwd: '/summary-grandchild', parentId: 'child', origin: 'subagent' },
])
await b.svc.refreshSubagents(sid('root'))
await b.svc.refreshSubagents(sid('child'))
b.svc.openSubagent({
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
})
expect(b.svc.list.getSnapshot().byId[sid('child')]?.displayTitle).toBe('Child')
expect(b.svc.list.getSnapshot().byId[sid('grandchild')]?.displayTitle).toBe('Grandchild')
})
it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async () => {
const b = bench()
b.api.onSubagentList = (payload) => {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md
README.md: 561fc68c6c002a7542303f8f95e5bd325cfc673e
README.zh.md: 7b80919a5b270eaca754ffccb99db04fb614882d
README.md: f6b3fa2e9cdf1479a739e0b4eab15a5423e878e4
README.zh.md: fdfba385e9188cd42bd973b6f32bc01fe8d004f2

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.slash`.
The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty catalog arrives it shows the healthy direct-child count and a compact tree in service order. Continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and session-summary activity time; an unlabeled one-shot row falls back to its session id. Corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch still lazily opens that child's authoritative direct catalog and reports every visible branch to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, and keyboard focus. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only.
The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and session-summary activity time; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, and keyboard focus. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only.
A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).

View File

@@ -4,7 +4,7 @@
Web subagent 功能 owner`conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空目录到达后,它会显示健康的直接 child 数量,并按服务顺序显示一棵紧凑树。可继续和 one-shot 行会显示 mode、`running``inactive` 活动状态、由日志支撑的可选 title 与会话摘要中的活动时间;没有 label 的 one-shot 行会回退到其会话 id损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时仍会懒加载该 child 的权威直接目录,并向运行时报告每个可见分支,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRightArrowLeft 展开和折叠分支ArrowUpArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running``inactive` 活动状态、由日志支撑的可选 title 与会话摘要中的活动时间;没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRightArrowLeft 展开和折叠分支ArrowUpArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
one-shot child 始终选用只读编辑器,并将 transcript文本记录说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome其 Session 会通过 `subagent.prompt` 路由child 运行期间,输入操作仍为 Send因为每条后续消息都会进入 child 的 FIFO inbox且已寻址会话绝不公开 Stop。本包绝不接收宿主 context也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。

View File

@@ -17,6 +17,17 @@
cursor: pointer;
}
.count {
margin: 0 5px;
}
.activitySlot {
display: inline-flex;
flex: none;
width: 10px;
height: 10px;
}
.trigger:hover,
.trigger:focus-visible {
color: var(--dsw-alias-label-secondary);
@@ -111,6 +122,10 @@
background: transparent;
}
.loadingRow {
cursor: default;
}
.disclosure,
.disclosureSpace {
flex: none;

View File

@@ -68,15 +68,78 @@ function relativeTime(updatedAt: number | undefined, now: number): string | unde
return `${Math.floor(diff / (365 * day))}`
}
/** Aggregate the complete subagent-only descendant subtree from flat summaries. */
function summarizeDescendants(
sessionId: SessionId,
summaries: Readonly<Record<SessionId, SessionSummary>>,
): { count: number; running: boolean } {
let count = 0
let running = false
for (const summary of Object.values(summaries)) {
if (summary.origin !== 'subagent') continue
const seen = new Set<SessionId>()
let current: SessionSummary | undefined = summary
while (current?.origin === 'subagent' && current.parentId !== undefined
&& !seen.has(current.id)) {
seen.add(current.id)
if (current.parentId === sessionId) {
count += 1
running ||= summary.running
break
}
current = summaries[current.parentId]
}
}
return { count, running }
}
/** Render the known direct-child shape while its authoritative catalog hydrates. */
function CatalogLoadingRows({
parentSessionId,
summaries,
level,
}: {
parentSessionId: SessionId
summaries: Readonly<Record<SessionId, SessionSummary>>
level: number
}) {
const children = Object.values(summaries).filter(summary => (
summary.origin === 'subagent' && summary.parentId === parentSessionId
))
if (children.length === 0) return <div className={css.notice}></div>
return children.map(summary => (
<div key={summary.id} className={css.node}>
<div
role="treeitem"
aria-disabled="true"
aria-level={level}
aria-label="正在加载子代理"
className={`${css.row} ${css.disabled} ${css.loadingRow}`}
>
<span className={css.disclosureSpace} />
<StateDot state={summary.running ? 'ongoing' : 'done'} />
<span className={css.content}>
<span className={css.label}></span>
</span>
</div>
</div>
))
}
/** Render one catalog level and recurse only through explicitly expanded rows. */
function CatalogRows({
parentSessionId, catalog, catalogs, summaries, expanded, level, now,
openChild, refresh, toggleBranch, closeCatalog,
}: CatalogRowsProps) {
const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0
return (
<>
{catalog.state === 'loading' && catalog.entries.length === 0 && (
<div className={css.notice}></div>
{emptyLoading && (
<CatalogLoadingRows
parentSessionId={parentSessionId}
summaries={summaries}
level={level}
/>
)}
{catalog.state === 'error' && (
<div className={css.error}>
@@ -118,7 +181,8 @@ function CatalogRows({
const childCatalog = catalogs[entry.id]
const isExpanded = expanded.has(entry.id)
const knownLeaf = !entry.hasChildren
const emptyLoading = childCatalog?.state === 'loading' && childCatalog.entries.length === 0
const childLoading = childCatalog === undefined
|| (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
const summary = summaries[entry.id]
const label = entry.label ?? entry.id
const mode = entry.mode === 'one-shot' ? '一次性' : '可继续'
@@ -186,25 +250,35 @@ function CatalogRows({
{time !== undefined && <span className={css.time}>{time}</span>}
</div>
</div>
{isExpanded && childCatalog !== undefined && !knownLeaf && (
{isExpanded && !knownLeaf && (
<div
role="group"
className={css.children}
aria-busy={emptyLoading || undefined}
aria-busy={childLoading || undefined}
>
<CatalogRows
parentSessionId={entry.id}
catalog={childCatalog}
catalogs={catalogs}
summaries={summaries}
expanded={expanded}
level={level + 1}
now={now}
openChild={openChild}
refresh={refresh}
toggleBranch={toggleBranch}
closeCatalog={closeCatalog}
/>
{childCatalog === undefined
? (
<CatalogLoadingRows
parentSessionId={entry.id}
summaries={summaries}
level={level + 1}
/>
)
: (
<CatalogRows
parentSessionId={entry.id}
catalog={childCatalog}
catalogs={catalogs}
summaries={summaries}
expanded={expanded}
level={level + 1}
now={now}
openChild={openChild}
refresh={refresh}
toggleBranch={toggleBranch}
closeCatalog={closeCatalog}
/>
)}
</div>
)}
</div>
@@ -233,6 +307,10 @@ export function SubagentCatalogAction({
const setCatalogOpenRef = useRef(setCatalogOpen)
setCatalogOpenRef.current = setCatalogOpen
const healthy = catalog?.entries.filter(entry => entry.kind === 'child') ?? []
const descendants = summarizeDescendants(sessionId, summaries)
// The catalog can arrive before the session-list baseline; never undercount
// the already-visible direct rows during that short bootstrap window.
const descendantCount = Math.max(healthy.length, descendants.count)
const observeCatalog = (parentSessionId: SessionId, next: boolean): void => {
if (next) observedCatalogs.current.add(parentSessionId)
@@ -341,6 +419,7 @@ export function SubagentCatalogAction({
className={css.trigger}
aria-haspopup="tree"
aria-expanded={open}
aria-label={`${descendantCount} 个子代理${descendants.running ? ',正在运行' : ''}`}
onClick={() => { changeOpen(!open) }}
onKeyDown={(event) => {
if (event.key !== 'ArrowDown') return
@@ -349,7 +428,10 @@ export function SubagentCatalogAction({
queueMicrotask(() => { focusAt(0) })
}}
>
<span>{healthy.length} </span>
<span className={css.activitySlot}>
{descendants.running && <StateDot state="ongoing" />}
</span>
<span className={css.count}>{descendantCount} </span>
<IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />
</button>
{open && (

View File

@@ -84,6 +84,54 @@ function summary(id: SessionId, updatedAt: number): SessionSummary {
}
describe('SubagentCatalogAction', () => {
it('aggregates live descendant activity onto the closed trigger', () => {
const summaries: Record<SessionId, SessionSummary> = {
[CHILD]: {
...summary(CHILD, Date.now()),
parentId: PARENT,
origin: 'subagent',
},
[GRANDCHILD]: {
...summary(GRANDCHILD, Date.now()),
parentId: CHILD,
origin: 'subagent',
running: true,
},
['child-2' as SessionId]: {
...summary('child-2' as SessionId, Date.now()),
parentId: PARENT,
origin: 'subagent',
},
}
const view = render(<SubagentCatalogAction {...props(catalog(), {}, summaries)} />)
const trigger = screen.getByRole('button', { name: '3 个子代理,正在运行' })
expect(trigger.querySelector('[data-state="ongoing"]')).not.toBeNull()
view.rerender(<SubagentCatalogAction {...props(catalog(), {}, {
...summaries,
[GRANDCHILD]: { ...summaries[GRANDCHILD]!, running: false },
})} />)
expect(screen.getByRole('button', { name: '3 个子代理' })
.querySelector('[data-state="ongoing"]')).toBeNull()
})
it('does not aggregate subagents reached through an ordinary fork', () => {
const fork = 'fork' as SessionId
const forkChild = 'fork-child' as SessionId
render(<SubagentCatalogAction {...props(catalog(), {}, {
[CHILD]: { ...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' },
['child-2' as SessionId]: {
...summary('child-2' as SessionId, 1), parentId: PARENT, origin: 'subagent',
},
[fork]: { ...summary(fork, 1), parentId: PARENT },
[forkChild]: { ...summary(forkChild, 1), parentId: fork, origin: 'subagent', running: true },
})} />)
const trigger = screen.getByRole('button', { name: '2 个子代理' })
expect(trigger.querySelector('[data-state="ongoing"]')).toBeNull()
})
it('renders healthy counts, stable rows, diagnostics, and catalog-addressed navigation', () => {
const input = props(catalog())
render(<SubagentCatalogAction {...input} />)
@@ -237,29 +285,54 @@ describe('SubagentCatalogAction', () => {
expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, false)
})
it('shows initial descendant loading status without drawing its branch', () => {
const loading = props(catalog(), {
[CHILD]: catalog({ entries: [], state: 'loading' }),
})
const view = render(<SubagentCatalogAction {...loading} />)
it('shows known descendant rows while their catalog loads', () => {
const secondGrandchild = 'grandchild-2' as SessionId
const summaries = {
[GRANDCHILD]: {
...summary(GRANDCHILD, 1), parentId: CHILD, origin: 'subagent' as const,
},
[secondGrandchild]: {
...summary(secondGrandchild, 1), parentId: CHILD, origin: 'subagent' as const,
running: true,
},
}
const deferred = props(catalog(), {}, summaries)
const view = render(<SubagentCatalogAction {...deferred} />)
fireEvent.click(screen.getByRole('button', { name: /2 个子代理/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
expect(loading.setCatalogOpen).toHaveBeenCalledWith(CHILD, true)
expect(deferred.setCatalogOpen).toHaveBeenCalledWith(CHILD, true)
expect(screen.getByRole('group').getAttribute('aria-busy')).toBe('true')
expect(screen.getByText('正在加载子代理')).toBeTruthy()
const loadingRows = screen.getAllByRole('treeitem', { name: '正在加载子代理' })
expect(loadingRows).toHaveLength(2)
expect(loadingRows.every(row => row.getAttribute('aria-level') === '2')).toBe(true)
expect(loadingRows[1]?.querySelector('[data-state="ongoing"]')).not.toBeNull()
const loading = props(catalog(), {
[CHILD]: catalog({ entries: [], state: 'loading' }),
}, summaries)
view.rerender(<SubagentCatalogAction {...loading} />)
expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
const ready = props(catalog(), {
[CHILD]: catalog({
entries: [{
kind: 'child', id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive', hasChildren: false,
}],
entries: [
{
kind: 'child', id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive', hasChildren: false,
},
{
kind: 'child', id: secondGrandchild, mode: 'one-shot',
label: 'critic', activity: 'running', hasChildren: false,
},
],
}),
})
}, summaries)
view.rerender(<SubagentCatalogAction {...ready} />)
expect(screen.getByRole('group').getAttribute('aria-busy')).toBeNull()
expect(screen.getByRole('treeitem', { name: /indexer/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /critic/ })).toBeTruthy()
expect(screen.queryByRole('treeitem', { name: '正在加载子代理' })).toBeNull()
})
it('uses ArrowRight and ArrowLeft for branch disclosure', async () => {