fix(ui-slash): make the reference lexicon reactive end to end
The decoration scan read a mutable lexicon() aggregation during render with no subscription, so a catalog settling or a child spawning after prewarm left drafted tokens undecorated until an unrelated re-render. The controller now publishes the aggregation as a snapshot store fed by a new optional SlashSource.subscribeLexicon hook (ui-skill notifies on settle/invalidate, ui-subagent forwards the session-list feed), the composer keyboard face exposes it as an observable, and InputBar subscribes through uSES. Sources registered after scope birth now warm and join live controllers via a service broadcast.
This commit is contained in:
@@ -32,17 +32,31 @@ function sessionsWith(sessions: SessionSummary[]) {
|
||||
const byId: Record<string, SessionSummary> = {}
|
||||
for (const s of sessions) byId[s.id] = s
|
||||
const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState
|
||||
return { list: { getSnapshot: () => snapshot } }
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
list: {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } },
|
||||
},
|
||||
notify: () => { for (const fn of [...subs]) fn() },
|
||||
listenerCount: () => subs.size,
|
||||
}
|
||||
}
|
||||
|
||||
/** Boot the plugin over fake slash/sessions faces; returns the captured source. */
|
||||
async function bench(sessions: SessionSummary[]): Promise<SlashSource> {
|
||||
/** Boot the plugin over fake slash/sessions faces; returns the captured source and the list face. */
|
||||
async function fullBench(sessions: SessionSummary[]) {
|
||||
const ctx = new Context()
|
||||
let captured: SlashSource | undefined
|
||||
const face = sessionsWith(sessions)
|
||||
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
|
||||
ctx.provide('sessions', sessionsWith(sessions))
|
||||
ctx.provide('sessions', face)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
return captured!
|
||||
return { source: captured!, face }
|
||||
}
|
||||
|
||||
/** Source-only bench for the behavior-contract suites. */
|
||||
async function bench(sessions: SessionSummary[]): Promise<SlashSource> {
|
||||
return (await fullBench(sessions)).source
|
||||
}
|
||||
|
||||
const FAMILY: SessionSummary[] = [
|
||||
@@ -113,6 +127,19 @@ describe('lexicon', () => {
|
||||
expect(source.lexicon!(proj('parent'))).toEqual(['worker-1', 'worker-2', 'scout'])
|
||||
expect(source.lexicon!(proj('childless'))).toEqual([])
|
||||
})
|
||||
|
||||
it('subscribeLexicon forwards the session-list change feed and unsubscribes cleanly', async () => {
|
||||
const { source, face } = await fullBench(FAMILY)
|
||||
let notified = 0
|
||||
const off = source.subscribeLexicon!(proj('parent'), () => { notified += 1 })
|
||||
expect(face.listenerCount()).toBe(1)
|
||||
face.notify()
|
||||
expect(notified).toBe(1)
|
||||
off()
|
||||
expect(face.listenerCount()).toBe(0)
|
||||
face.notify()
|
||||
expect(notified).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pick and codec', () => {
|
||||
|
||||
Reference in New Issue
Block a user