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
@@ -5,7 +5,7 @@
* conversation wiring layer alone sees the full SessionInput. InputMachine
* (machine.ts) is package-private and never exported.
*/
import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
ReferenceInsert, SubmitOutcome, TokenSpan,
@@ -99,8 +99,8 @@ export interface ComposerKeyboard {
space(): boolean
/** Dismiss the popupSelect shell (any interaction outside the box). */
dismissPopup(): void
/** Hot plain-text reference lexicons for the decoration scan (decision 21; empty Map without a pipeline). */
lexicon(): ReadonlyMap<'/' | '@', readonly string[]>
/** Hot plain-text reference lexicon source for the decoration scan (decision 21; empty Map without a pipeline). */
readonly lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
}
/** One queued-message row projected from the session/queued frames (T9 supplies the store). */
@@ -206,11 +206,14 @@ export class SessionInputShell implements SessionInput {
}
/**
* Hot plain-text reference lexicons for the decoration scan (decision 21).
* @returns the controller's per-trigger aggregation; empty Map without a pipeline.
* Hot plain-text reference lexicon source for the decoration scan
* (decision 21): delegates to the controller's aggregated store. Stable
* identity per shell; without a pipeline the snapshot is the empty Map and
* subscribers never fire.
*/
lexicon(): ReadonlyMap<'/' | '@', readonly string[]> {
return this.deps.slash?.()?.lexicon() ?? EMPTY_LEXICON
readonly lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>> = {
getSnapshot: () => this.deps.slash?.()?.lexicon.getSnapshot() ?? EMPTY_LEXICON,
subscribe: fn => this.deps.slash?.()?.lexicon.subscribe(fn) ?? (() => {}),
}
/**
@@ -36,6 +36,11 @@ export function InputBar({
(fn: () => void) => noticeStore.subscribe(fn),
() => noticeStore.getSnapshot(),
)
const lexiconStore = keyboard.lexicon
const lexicon = useSyncExternalStore(
(fn: () => void) => lexiconStore.subscribe(fn),
() => lexiconStore.getSnapshot(),
)
const promptError = useSession(s => s.promptError)
const running = useSession(s => s.running)
const disabled = useSession(s => s.removed)
@@ -244,7 +249,7 @@ export function InputBar({
// claim token highlights through behind the textarea glyphs; each U+FFFC
// placeholder renders as a chip (the textarea's own glyph is invisible, the
// backdrop chip supplies the visual); the claim hint is ghost text.
const deco = deriveDecorations(input, keyboard.lexicon())
const deco = deriveDecorations(input, lexicon)
const backdrop: ReactNode[] = []
{
// Segment boundaries: the token range end, every chip offset, and every