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

View File

@@ -40,18 +40,35 @@ export interface SlashControllerDeps {
export class SlashController {
/** Menu state store (per-session; survives session switches, dies with the scope). */
readonly menu: SnapshotStore<MenuState> = createSnapshotStore<MenuState>(MENU_CLOSED)
/**
* Aggregated hot reference lexicon, grouped by trigger (decision 21):
* sources implementing the lexicon hook are polled with the session
* projection; undefined answers (roll not hot yet) are skipped; multiple
* sources on one trigger concatenate in registration order. A snapshot
* store because rolls change asynchronously (catalog settles, children
* spawn/exit) — render-side consumers subscribe instead of re-reading a
* mutable answer.
*/
readonly lexicon: SnapshotStore<ReadonlyMap<TriggerChar, readonly string[]>> =
createSnapshotStore<ReadonlyMap<TriggerChar, readonly string[]>>(new Map())
/** The authoritative hit: single truth for span CAS material (menu snapshot never carries it alone). */
private hit: TriggerHit | null = null
private fetch: AbortController | null = null
private disposed = false
/** Per-source lexicon unsubscribers (sources without the hook never enter). */
private readonly lexiconOffs = new Map<SlashSource, () => void>()
constructor(private readonly deps: SlashControllerDeps) {
// Scope-birth prewarm: sessions are always agent-backed, so the one-time
// roster warm here replaces the projection-transition watch — there are
// no capability steps to react to.
const projection = this.project()
for (const src of deps.roster.all()) src.warm?.(projection)
for (const src of deps.roster.all()) {
src.warm?.(projection)
this.watchLexicon(src, projection)
}
this.refreshLexicon()
}
/**
@@ -220,6 +237,23 @@ export class SlashController {
if (state.open && state.hit !== null && state.hit.trigger === source.trigger) {
this.reduce({ type: 'source-failed', generation: state.generation, source: source.name })
}
this.lexiconOffs.get(source)?.()
this.lexiconOffs.delete(source)
this.refreshLexicon()
}
/**
* Admit a source registered after this controller's birth (root registry
* change notification): warm it and fold its roll into the live lexicon —
* the constructor-time prewarm covers only the roster present at scope
* birth.
* @param source - the newly registered source.
*/
sourceAdded(source: SlashSource): void {
const projection = this.project()
source.warm?.(projection)
this.watchLexicon(source, projection)
this.refreshLexicon()
}
/** Scope teardown: close and abort (the service deletes the map entry). */
@@ -228,6 +262,8 @@ export class SlashController {
this.stopFetch()
this.reduce({ type: 'close' })
this.hit = null
for (const off of this.lexiconOffs.values()) off()
this.lexiconOffs.clear()
}
/** The session projection handed to sources (agent-backed identity; constant per scope). */
@@ -248,15 +284,8 @@ export class SlashController {
return actx.bail(actx, 'slash/input-insert-reference', { reference: outcome.insert, span }) === true
}
/**
* Aggregate the sources' plain-text reference lexicons (decision 21),
* grouped by trigger: sources implementing the hook are polled with the
* session projection (onSpace's poll pattern); undefined answers (roll not
* hot yet) are skipped; multiple sources on one trigger concatenate in
* registration order.
* @returns trigger → decorated-name roll for the decoration scan.
*/
lexicon(): ReadonlyMap<TriggerChar, readonly string[]> {
/** Re-poll every lexicon-bearing source and publish the aggregated rolls (see the store doc). */
private refreshLexicon(): void {
const projection = this.project()
const rolls = new Map<TriggerChar, readonly string[]>()
for (const src of this.deps.roster.all()) {
@@ -266,7 +295,13 @@ export class SlashController {
const prev = rolls.get(src.trigger)
rolls.set(src.trigger, prev === undefined ? names : [...prev, ...names])
}
return rolls
this.lexicon.set(rolls)
}
/** Wire one source's lexicon invalidation channel into refresh (hookless or roll-less sources never notify). */
private watchLexicon(source: SlashSource, projection: ClientSessionContext): void {
if (source.lexicon === undefined || source.subscribeLexicon === undefined) return
this.lexiconOffs.set(source, source.subscribeLexicon(projection, () => { this.refreshLexicon() }))
}
/** Launch the candidate fetch for one hit generation, superseding the previous one. */

View File

@@ -38,7 +38,8 @@ export class SlashService extends Service implements SlashServiceContract {
}
/**
* Register one trigger source.
* Register one trigger source. Live session controllers are notified so a
* source arriving after scope birth still warms and joins the lexicon.
* @param src - the source; (trigger, name) must be unique — duplicates throw.
* @returns the disposer (callers wrap registration in ctx.effect). Disposal
* while a controller shows the source's menu group drops that group.
@@ -49,6 +50,7 @@ export class SlashService extends Service implements SlashServiceContract {
throw new Error(`slash source "${src.trigger}${src.name}" is already registered`)
}
live.sources.push(src)
for (const controller of live.controllers.values()) controller.sourceAdded(src)
return () => {
const at = live.sources.indexOf(src)
if (at < 0) return

View File

@@ -165,6 +165,16 @@ export interface SlashSource {
* (the render path must stay synchronous and side-effect free).
*/
lexicon?(session: ClientSessionContext): readonly string[] | undefined
/**
* Subscribe to changes of this source's {@link SlashSource.lexicon} answer
* for one session (backing data settled, invalidated, or refreshed). The
* controller re-polls lexicon on each notification; a source whose roll
* never changes after warm omits the hook.
* @param session - stable session projection.
* @param listener - invalidation callback.
* @returns unsubscribe.
*/
subscribeLexicon?(session: ClientSessionContext, listener: () => void): () => void
/** Reference codec; required for sources producing insert outcomes. */
readonly codec?: ReferenceCodec
}

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', () => {