fix(web): preserve subagent navigation and fork grouping

This commit is contained in:
imccyu
2026-08-01 15:35:50 +08:00
committed by Tianyi Cui
parent 8c9cd4c15d
commit 130410bb98
18 changed files with 296 additions and 62 deletions

View File

@@ -127,14 +127,14 @@ export class SessionManager {
// ---- Selection ----
/**
* Select a listed Session.
* @param sessionId - listed Session id.
* Select a listed Session or a retained catalog-addressed child.
* @param sessionId - listed or catalog-addressed Session id.
*/
select(sessionId: SessionId): void {
if (!this.summaries.some(summary => summary.sessionId === sessionId)) {
const address = this.addresses.get(sessionId)
if (!this.summaries.some(summary => summary.sessionId === sessionId) && address === undefined) {
throw new Error(`sessions.select: unknown session ${sessionId}`)
}
const address = this.addresses.get(sessionId)
this.sessions.get(sessionId)?.configureSubagent(
address,
address === undefined

View File

@@ -65,7 +65,9 @@ export interface SessionSummary {
* sidebar highlighting and SessionProvider share one fact source).
*/
export interface SessionListState {
/** Host-list order; addressed breadcrumb-only rows are excluded. */
ids: SessionId[]
/** Host rows plus the current addressed subagent route used by navigation. */
byId: Record<SessionId, SessionSummary>
current: SessionId | undefined
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
@@ -309,9 +311,8 @@ export class SessionsService implements ISessions {
}
/**
* Select a session as current. Unknown ids fail loud instead of navigating
* nowhere.
* @param id - session id (must exist in the list store).
* Select a listed or retained catalog-addressed session as current.
* @param id - listed or addressed session id.
*/
open(id: SessionId): void {
this.manager.select(id)
@@ -563,9 +564,9 @@ export class SessionsService implements ISessions {
}
/**
* Breadcrumb feed: walk parentId links inside the list store.
* Breadcrumb feed: walk subagent parent links inside the list store.
* @param id - session id.
* @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk).
* @returns The ordinary owner plus its subagent route, or only the requested ordinary/fork session.
*/
ancestry(id: SessionId): SessionSummary[] {
const { byId } = this.list.getSnapshot()
@@ -575,6 +576,7 @@ export class SessionsService implements ISessions {
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
@@ -582,10 +584,9 @@ export class SessionsService implements ISessions {
/**
* Lazily mint the scope + binding for an eligible session. Eligibility and
* prune share one predicate (decision 12): listed on the host — a scope is
* born when its session enters the client's view (list mirror row from the
* baseline pull, a create() echo, or the session-added frame) and dies with
* the prune when the row leaves.
* prune share one predicate (decision 12): listed on the host or selected
* through a retained subagent address. Breadcrumb-only ancestors remain
* summary data and do not keep scopes alive.
*/
private resolve(id: SessionId): ScopeRecord | undefined {
const existing = this.scopes.get(id)
@@ -609,9 +610,10 @@ export class SessionsService implements ISessions {
return record
}
/** The one aliveness predicate shared by scope mint and prune: host-listed. */
/** The one aliveness predicate shared by scope mint and prune: host-listed or currently addressed. */
private eligible(id: SessionId): boolean {
return this.list.getSnapshot().byId[id] !== undefined
const { ids, current } = this.list.getSnapshot()
return current === id || ids.includes(id)
}
/** Project the manager's list snapshot into the store (title derivation is display-only). */
@@ -636,20 +638,29 @@ export class SessionsService implements ISessions {
...(entry.origin !== undefined ? { origin: entry.origin } : {}),
}
}
if (current !== undefined && currentAddress !== undefined && byId[current] === undefined) {
const child = subagentsByParent[currentAddress.parentSessionId]?.entries
.find(entry => entry.kind === 'child' && entry.id === current)
if (child?.kind === 'child') {
byId[current] = {
id: current,
displayTitle: child.label ?? current,
parentId: currentAddress.parentSessionId,
origin: 'subagent',
running: child.activity === 'running',
waitingApproval: false,
blank: false,
updatedAt: 0,
if (current !== undefined && currentAddress !== undefined) {
const seen = new Set<SessionId>()
let address: SubagentAddress | undefined = currentAddress
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
byId[childId] = {
id: childId,
displayTitle: child.label ?? childId,
parentId: address.parentSessionId,
origin: 'subagent',
running: child.activity === 'running',
waitingApproval: false,
blank: false,
updatedAt: 0,
}
}
if (byId[address.parentSessionId] !== undefined) break
address = this.manager.subagentAddress(address.parentSessionId)
}
}
const persisted = this.selection.getSnapshot().sessionId
@@ -668,12 +679,11 @@ export class SessionsService implements ISessions {
})
}
this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress })
this.pruneScopes(byId)
this.pruneScopes()
}
/** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
void byId
private pruneScopes(): void {
for (const [id, record] of this.scopes) {
if (this.eligible(id)) continue
if (id === this.watched) {

View File

@@ -362,18 +362,73 @@ describe('slot-store scope prune hook', () => {
})
describe('ancestry', () => {
it('walks parentId links root-first including self; broken links stop the walk', async () => {
it('walks only subagent lineage and includes its first ordinary owner', async () => {
const b = bench()
await feedList(b, [
{ id: 'root', cwd: '/w/app' },
{ id: 'mid', parentId: 'root' },
{ id: 'leaf', parentId: 'mid' },
{ id: 'orphan', parentId: 'ghost' },
{ 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('leaf')).map(s => s.id)).toEqual(['root', 'mid', 'leaf'])
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 () => {
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', parentId: 'root', origin: 'subagent' },
{ id: 'grandchild', parentId: 'child', origin: 'subagent' },
])
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(b.svc.binding(sid('child'))).toBeUndefined()
b.svc.open(sid('child'))
expect(b.svc.list.getSnapshot().current).toBe(sid('child'))
expect(b.svc.subagentAddress(sid('child'))).toEqual({
parentSessionId: sid('root'), childSessionId: sid('child'), mode: 'continuable',
})
})
})
describe('create', () => {

View File

@@ -17,6 +17,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
const summary: SessionSummary | undefined = list.byId[cursor]
if (summary === undefined || chain.includes(summary)) break
chain.unshift(summary)
if (summary.origin !== 'subagent') break
cursor = summary.parentId
}
return chain
@@ -24,7 +25,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
export function ConversationSession({
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror, open, wrapActiveBody, t
renderSlot, views, bindDraftMirror, open, wrapActiveBody, t,
}: ConversationSessionProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()

View File

@@ -87,6 +87,8 @@ function mount(
summaryBlank?: boolean
/** Drop the session's summary row entirely (a session the list has not caught up with). */
omitSummaryRow?: boolean
/** Classify the selected child as a subagent instead of an ordinary fork. */
summaryOrigin?: 'subagent'
} = {},
) {
const root = sid('root')
@@ -94,6 +96,7 @@ function mount(
const childRow = {
id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one',
running: false, waitingApproval: false, blank: options.summaryBlank ?? false, updatedAt: 2,
...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }),
}
const listed = options.omitSummaryRow !== true
const sessions = createSnapshotStore<SessionListState>({
@@ -203,7 +206,7 @@ function mount(
}
const view = render(<ConversationRoot {...props} />)
return {
view, chat, sink, retargetWorkspace, session, slotCalls,
view, chat, sink, retargetWorkspace, session, slotCalls, open,
pickerOwner: () => pickerOwner,
rerender: () => { view.rerender(<ConversationRoot {...props} />) },
}
@@ -218,10 +221,18 @@ describe('ConversationRoot resident composer', () => {
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.sink).toHaveBeenCalledWith('ordinary revised')
expect(b.view.getByRole('heading', { name: 'Child', level: 1 })).toBeTruthy()
expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true)
expect(b.view.queryByText('Root')).toBeNull()
})
it('shows hierarchy only for subagents and opens their ordinary owner', () => {
const b = mount(conversationSnapshot(), undefined, undefined, { summaryOrigin: 'subagent' })
const root = b.view.getByRole('button', { name: 'Root' })
expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(root)
expect(b.open).toHaveBeenCalledWith(sid('root'))
})
it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => {
const b = mount(conversationSnapshot())
const host = b.view.container.querySelector('[data-conversation-scroll]')