fix(web): stabilize nested subagent navigation

This commit is contained in:
imccyu
2026-08-01 18:23:07 +08:00
committed by Tianyi Cui
parent 53ae9abea2
commit b94d2f9c1d
9 changed files with 129 additions and 74 deletions

View File

@@ -55,6 +55,11 @@ export interface SubagentCatalogSnapshot extends SubagentCatalog {
error: RpcError | null
}
interface CatalogInflight {
readonly promise: Promise<void>
readonly expandableRows: Set<SessionId>
}
type SessionListMutation =
| { kind: 'upsert'; summary: SessionSummary }
| { kind: 'remove'; sessionId: SessionId }
@@ -94,7 +99,7 @@ export class SessionManager {
private listMutations: SessionListMutation[] | null = null
private readonly addresses = new Map<SessionId, SubagentAddress>()
private readonly catalogs = new Map<SessionId, SubagentCatalogSnapshot>()
private readonly catalogInflight = new Map<SessionId, Promise<void>>()
private readonly catalogInflight = new Map<SessionId, CatalogInflight>()
private readonly openCatalogs = new Set<SessionId>()
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
@@ -131,10 +136,11 @@ export class SessionManager {
* @param sessionId - listed or catalog-addressed Session id.
*/
select(sessionId: SessionId): void {
const address = this.addresses.get(sessionId)
const address = this.navigationAddress(sessionId)
if (!this.summaries.some(summary => summary.sessionId === sessionId) && address === undefined) {
throw new Error(`sessions.select: unknown session ${sessionId}`)
}
if (address !== undefined) this.addresses.set(sessionId, address)
this.sessions.get(sessionId)?.configureSubagent(
address,
address === undefined
@@ -178,6 +184,23 @@ export class SessionManager {
return this.addresses.get(sessionId)
}
/**
* Resolve an address for breadcrumb navigation without retaining transport authority.
* @param sessionId - possible child id in an already-loaded catalog.
* @returns A retained or catalog-derived direct-parent address.
*/
navigationAddress(sessionId: SessionId): SubagentAddress | undefined {
const retained = this.addresses.get(sessionId)
if (retained !== undefined) return retained
for (const [parentSessionId, catalog] of this.catalogs) {
const child = catalog.entries.find(entry => entry.kind === 'child' && entry.id === sessionId)
if (child?.kind === 'child') {
return { parentSessionId, childSessionId: sessionId, mode: child.mode }
}
}
return undefined
}
// ---- Instance management ----
/**
@@ -262,8 +285,9 @@ export class SessionManager {
*/
refreshSubagents(parentSessionId: SessionId): Promise<void> {
const existing = this.catalogInflight.get(parentSessionId)
if (existing !== undefined) return existing
if (existing !== undefined) return existing.promise
const previous = this.catalogs.get(parentSessionId)
const expandableRows = new Set<SessionId>()
this.catalogs.set(parentSessionId, {
entries: previous?.entries ?? [],
parentAvailable: previous?.parentAvailable ?? false,
@@ -277,6 +301,7 @@ export class SessionManager {
if (result.ok) {
this.catalogs.set(parentSessionId, {
...result.value,
entries: this.withExpandableRows(result.value.entries, expandableRows),
state: 'ready',
error: null,
})
@@ -286,7 +311,7 @@ export class SessionManager {
}
} else {
this.catalogs.set(parentSessionId, {
entries: previous?.entries ?? [],
entries: this.withExpandableRows(previous?.entries ?? [], expandableRows),
parentAvailable: previous?.parentAvailable ?? false,
state: 'error',
error: result.error,
@@ -295,7 +320,7 @@ export class SessionManager {
} catch (error: unknown) {
const folded = transportError<never>(error)
this.catalogs.set(parentSessionId, {
entries: previous?.entries ?? [],
entries: this.withExpandableRows(previous?.entries ?? [], expandableRows),
parentAvailable: previous?.parentAvailable ?? false,
state: 'error',
error: folded.ok ? null : folded.error,
@@ -305,7 +330,7 @@ export class SessionManager {
this.notifier.markDirty()
}
})()
this.catalogInflight.set(parentSessionId, operation)
this.catalogInflight.set(parentSessionId, { promise: operation, expandableRows })
return operation
}
@@ -717,8 +742,14 @@ export class SessionManager {
if (changed) this.notifier.markDirty()
}
/** Mark a loaded parent row expandable after one direct subagent publishes. */
/** Preserve and project a positive expandability hint after one direct subagent publishes. */
private markCatalogParentExpandable(parentSessionId: SessionId): void {
this.applyCatalogParentExpandable(parentSessionId)
for (const inflight of this.catalogInflight.values()) inflight.expandableRows.add(parentSessionId)
}
/** Apply one positive expandability hint to every loaded catalog containing that unique row id. */
private applyCatalogParentExpandable(parentSessionId: SessionId): void {
let changed = false
for (const [catalogParentId, catalog] of this.catalogs) {
if (!catalog.entries.some(entry =>
@@ -733,6 +764,16 @@ export class SessionManager {
if (changed) this.notifier.markDirty()
}
/** Fold request-local positive row mutations into one catalog result before publication. */
private withExpandableRows(
entries: SubagentCatalog['entries'],
expandableRows: ReadonlySet<SessionId>,
): SubagentCatalog['entries'] {
return entries.map(entry => entry.kind === 'child' && expandableRows.has(entry.id)
? { ...entry, hasChildren: true }
: entry)
}
private buildListSnapshot(): SessionListSnapshot {
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
// List rows read the generic 'title' projection key (host-computed unit

View File

@@ -4,7 +4,7 @@
* session-scoped surface keys off — migrated here from ui-layout per the
* slot-parity design), Agent scope tree (mintScope pattern: no-op plugin
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
* id), stable SessionBinding cache, ancestry walk.
* id), stable SessionBinding cache, breadcrumb-route projection.
*
* Scope lifecycle is stage-driven: a scope is minted lazily on first
* resolution (pure — resolution has no side effects and is render-safe);
@@ -206,7 +206,7 @@ export interface SessionProvideDescriptor {
resolve(binding: SessionBinding): SessionProvideContribution
}
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, and breadcrumb routes. */
export class SessionsService implements ISessions {
/**
* The wire schema's own result bound, re-exposed for presentation plugins as
@@ -563,25 +563,6 @@ export class SessionsService implements ISessions {
}
}
/**
* Breadcrumb feed: walk subagent parent links inside the list store.
* @param id - session id.
* @returns The ordinary owner plus its subagent route, or only the requested ordinary/fork session.
*/
ancestry(id: SessionId): SessionSummary[] {
const { byId } = this.list.getSnapshot()
const chain: SessionSummary[] = []
let cursor: SessionId | undefined = id
while (cursor !== undefined) {
const summary: SessionSummary | undefined = byId[cursor]
if (summary === undefined || chain.includes(summary)) break
chain.unshift(summary)
if (summary.origin !== 'subagent') break
cursor = summary.parentId
}
return chain
}
/**
* Lazily mint the scope + binding for an eligible session. Eligibility and
* prune share one predicate (decision 12): listed on the host or selected
@@ -660,7 +641,7 @@ export class SessionsService implements ISessions {
}
}
if (byId[address.parentSessionId] !== undefined) break
address = this.manager.subagentAddress(address.parentSessionId)
address = this.manager.navigationAddress(address.parentSessionId)
}
}
const persisted = this.selection.getSnapshot().sessionId

View File

@@ -426,6 +426,47 @@ describe('subagent catalogs', () => {
{ kind: 'child', id: S2, hasChildren: false },
])
})
it('preserves a live expandability hint across only the 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: 'nested-subagent' as never,
payload: {
type: 'host/session-added', sessionId: 'fk-grandchild' as SessionId,
parentSessionId: S1, origin: 'subagent', blank: false,
},
})
response.resolve(ok({
entries: [{
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
await refresh
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, hasChildren: true },
])
api.onSubagentList = () => Promise.resolve(ok({
entries: [{
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
await manager.refreshSubagents(root)
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, hasChildren: false },
])
})
})
describe('remaining branches', () => {

View File

@@ -3,8 +3,8 @@
* with derived titles), the migrated current-selection account (open
* validation, persisted mask semantics, cell resolution), scope-tree
* lifecycle (lazy mint / frozen survival / removed teardown with staged
* deferral — the stage follows list.current), binding identity, ancestry
* walk, create.
* deferral — the stage follows list.current), binding identity, breadcrumb
* projection, create.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -361,24 +361,8 @@ describe('slot-store scope prune hook', () => {
})
})
describe('ancestry', () => {
it('walks only subagent lineage and includes its first ordinary owner', async () => {
const b = bench()
await feedList(b, [
{ id: 'root', cwd: '/w/app' },
{ id: 'fork', parentId: 'root' },
{ id: 'child', parentId: 'fork', origin: 'subagent' },
{ id: 'grandchild', parentId: 'child', origin: 'subagent' },
{ id: 'orphan', parentId: 'ghost', origin: 'subagent' },
])
expect(b.svc.ancestry(sid('fork')).map(s => s.id)).toEqual(['fork'])
expect(b.svc.ancestry(sid('child')).map(s => s.id)).toEqual(['fork', 'child'])
expect(b.svc.ancestry(sid('grandchild')).map(s => s.id)).toEqual(['fork', 'child', 'grandchild'])
expect(b.svc.ancestry(sid('orphan')).map(s => s.id)).toEqual(['orphan'])
expect(b.svc.ancestry(sid('ghost'))).toEqual([])
})
it('retains a cold nested subagent route without retaining ancestor scopes', async () => {
describe('catalog-addressed navigation', () => {
it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async () => {
const b = bench()
b.api.onSubagentList = (payload) => {
const { parentSessionId } = payload as { parentSessionId: SessionId }
@@ -402,26 +386,19 @@ describe('ancestry', () => {
}
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
}
await feedList(b, [
{ id: 'root' },
{ id: 'child', parentId: 'root', origin: 'subagent' },
{ id: 'grandchild', parentId: 'child', origin: 'subagent' },
])
await feedList(b, [{ id: 'root' }])
await b.svc.refreshSubagents(sid('root'))
b.svc.openSubagent({
parentSessionId: sid('root'), childSessionId: sid('child'), mode: 'continuable',
})
await b.svc.refreshSubagents(sid('child'))
b.svc.openSubagent({
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
})
await feedList(b, [{ id: 'root' }])
const list = b.svc.list.getSnapshot()
expect(list.ids).toEqual([sid('root')])
expect(b.svc.ancestry(sid('grandchild')).map(summary => summary.id))
.toEqual([sid('root'), sid('child'), sid('grandchild')])
expect(list.byId[sid('child')]).toMatchObject({ parentId: sid('root'), origin: 'subagent' })
expect(list.byId[sid('grandchild')]).toMatchObject({ parentId: sid('child'), origin: 'subagent' })
expect(b.svc.binding(sid('child'))).toBeUndefined()
expect(b.svc.subagentAddress(sid('child'))).toBeUndefined()
b.svc.open(sid('child'))
expect(b.svc.list.getSnapshot().current).toBe(sid('child'))