fix(web): synchronize subagent navigation state
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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-flight;Host 基线与 `host/session-status` 都根据 child Agent driver 状态推导活动状态,读取期间收到的状态帧会在该读取的响应之上回放。按 origin 分类的 `host/session-added` 会立即把任何已加载的直接 parent 行标记为 `hasChildren: true`,并在该 parent 被选中或其目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、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()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、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)规定。
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user