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
+21 -1
View File
@@ -44,6 +44,12 @@ export function apply(ctx: ClientContext): void {
// Session-keyed catalog cache; single-flight per key. Plugin-closure state:
// the fiber effect below is its teardown boundary.
const fetches = new Map<SessionId, CatalogFetch>()
// Per-session lexicon invalidation listeners (subscribeLexicon consumers).
const lexiconListeners = new Map<SessionId, Set<() => void>>()
const notifyLexicon = (sessionId: SessionId): void => {
for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) listener()
}
const fetchCatalog = (sessionId: SessionId): Promise<readonly SkillEntry[]> => {
const existing = fetches.get(sessionId)
@@ -58,7 +64,10 @@ export function apply(ctx: ClientContext): void {
fetches.set(sessionId, entry)
promise.then(
// Settled snapshot backs the synchronous lexicon reads.
(skills) => { entry.settled = skills },
(skills) => {
entry.settled = skills
notifyLexicon(sessionId)
},
// A failed fetch must not poison the key: the next consumer retries.
() => {
if (fetches.get(sessionId) === entry) fetches.delete(sessionId)
@@ -72,6 +81,7 @@ export function apply(ctx: ClientContext): void {
if (entry === undefined) return
fetches.delete(key)
entry.abort.abort()
notifyLexicon(key)
}
const clearAll = (): void => {
@@ -97,6 +107,16 @@ export function apply(ctx: ClientContext): void {
lexicon(session) {
return fetches.get(session.sessionId)?.settled?.map(skill => skill.name)
},
subscribeLexicon(session, listener) {
const key = session.sessionId
const listeners = lexiconListeners.get(key) ?? new Set()
listeners.add(listener)
lexiconListeners.set(key, listeners)
return () => {
listeners.delete(listener)
if (listeners.size === 0) lexiconListeners.delete(key)
}
},
onPick({ candidate }) {
// Decision 21: plain-text reference — the literal lands in the draft
// and ships to the model verbatim (trailing space closes the token).