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:
imccyu
2026-07-28 02:54:24 +08:00
parent eae712409b
commit d3d01cb49c
12 changed files with 232 additions and 30 deletions
+68 -3
View File
@@ -126,6 +126,18 @@ describe('registerSource', () => {
slash.registerSource(deferredSource('/', 'beta').source)
})
it('a source registered after controller birth warms in every live controller', async () => {
const { slash, mint } = await serviceBench()
const ca = slash.sessionOf(mint('a').actx)
const cb = slash.sessionOf(mint('b').actx)
const late = deferredSource('/', 'late', { lexicon: () => ['fresh'] })
slash.registerSource(late.source)
expect(late.warm).toHaveBeenNthCalledWith(1, { sessionId: sid('a') })
expect(late.warm).toHaveBeenNthCalledWith(2, { sessionId: sid('b') })
expect(ca.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
expect(cb.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
})
it('HMR shape: dispose of the registering fiber removes the source', async () => {
const { root, slash, mint } = await serviceBench()
const controller = slash.sessionOf(mint('a').actx)
@@ -513,7 +525,7 @@ describe('lexicon', () => {
skill,
lexSource('@', 'subagent', ['worker-1']),
])
const rolls = controller.lexicon()
const rolls = controller.lexicon.getSnapshot()
expect([...rolls.keys()]).toEqual(['/', '@'])
expect(rolls.get('/')).toEqual(['commit-helper', 'review'])
expect(rolls.get('@')).toEqual(['worker-1'])
@@ -522,7 +534,7 @@ describe('lexicon', () => {
it('an undefined answer (roll not hot) is skipped without seeding the trigger', () => {
const { controller } = controllerBench([lexSource('/', 'skill', undefined)])
expect(controller.lexicon().size).toBe(0)
expect(controller.lexicon.getSnapshot().size).toBe(0)
})
it('two sources on one trigger concatenate in registration order', () => {
@@ -531,10 +543,63 @@ describe('lexicon', () => {
lexSource('/', 'prompt', ['c']),
lexSource('@', 'subagent', undefined), // not hot: '@' stays absent
])
const rolls = controller.lexicon()
const rolls = controller.lexicon.getSnapshot()
expect(rolls.get('/')).toEqual(['b', 'a', 'c'])
expect(rolls.has('@')).toBe(false)
})
it('a source lexicon notification republishes the aggregated store', () => {
let roll: readonly string[] | undefined = undefined
let notify: (() => void) | undefined
const source: SlashSource = {
trigger: '/',
name: 'skill',
candidates: () => Promise.resolve([]),
onPick: () => undefined,
lexicon: () => roll,
subscribeLexicon: (_session, listener) => {
notify = listener
return () => { notify = undefined }
},
}
const { controller } = controllerBench([source])
expect(controller.lexicon.getSnapshot().size).toBe(0)
const seen: number[] = []
controller.lexicon.subscribe(() => { seen.push(controller.lexicon.getSnapshot().size) })
roll = ['commit-helper']
notify?.()
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['commit-helper'])
expect(seen).toEqual([1])
controller.dispose()
expect(notify).toBeUndefined()
})
it('a source registered after scope birth is warmed and folded into the live lexicon', () => {
const { controller, sources } = controllerBench([])
expect(controller.lexicon.getSnapshot().size).toBe(0)
const warm = vi.fn()
const late: SlashSource = {
trigger: '/',
name: 'late',
candidates: () => Promise.resolve([]),
onPick: () => undefined,
warm,
lexicon: () => ['fresh'],
}
sources.push(late)
controller.sourceAdded(late)
expect(warm).toHaveBeenCalledWith({ sessionId: sid('a') })
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
})
it('a removed source leaves the aggregated lexicon', () => {
const src = lexSource('/', 'skill', ['gone'])
const { controller, sources } = controllerBench([src])
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['gone'])
sources.splice(sources.indexOf(src), 1)
controller.sourceRemoved(src)
expect(controller.lexicon.getSnapshot().size).toBe(0)
})
})
describe('arbitrate', () => {