Ver código fonte

feat(ui-slots): bind inject hooks compartments into use<Name> selector hooks

Registrant-private reactive facts previously reached components as raw
observables that each component subscribed by hand (InputBar notices/
lexicon via uSES, SettingsRoot via a version/subscribe/getter triple).
The inject face now carries a reserved hooks compartment of bare
sources; the renderer binds each into a use<Name> selector hook through
the same machinery as the provide channel, so components consume
useNotices/useLexicon/useSections and never see a subscription
primitive. InputBar and SettingsRoot are the first two consumers.
imccyu 1 mês atrás
pai
commit
b5168bbf86

+ 3 - 3
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write
-2026-07-22-slot-type-chain-implementation.md: 617524475f3da8af5d281efcfe8f79d500f31be8
-2026-07-22-slot-type-chain-implementation.zh.md: 52edea30acea5989b3438cbcf4688df5a897f099
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
+2026-07-22-slot-type-chain-implementation.md: e88361701fc05c1ab30174dde147ae9558265ce6
+2026-07-22-slot-type-chain-implementation.zh.md: 90473861c199f326f4b3635580c885517f0d612b

+ 4 - 4
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md

@@ -45,7 +45,7 @@ Parity rule: **the declaring entry holds the exclusive right to render its child
 | runtime | `PropsRuntime<K>` | SlotMap entry for K | `OwnerOf<K>` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions`/`useWorkspaces` |
 | child render | `PropsRenderSlots<S>` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` |
 | store | `PropsStore<H>` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) |
-| business | `I` | inject return type | plain data + callbacks (hooks banned) |
+| business | `I` | inject return type | plain data + callbacks; a reserved `hooks` compartment of bare observables arrives bound as `use<Name>` selector hooks (`InjectFace<I>`) |
 
 `sessionId` is framework-supplied wherever `scope: 'session'` is declared — owner params do not carry it. The register call site is the double-lock choke point: a component whose renderSlot keys exceed the `children` declaration, or that misses a declared face, or whose store/inject shapes drift, is a compile error on that line. Delegation is ordinary props passing (hand the `renderSlot` function down, optionally behind a narrower signature) — there is no whitelist face object and no minting API.
 
@@ -80,11 +80,11 @@ Store scope is **derived from the mounting entry's scope** (session slot → one
 
 ### inject: the registrant's business face, on its own ctx
 
-An inject factory takes what its declarations earn it — `sessionId` for session slots, bound `actions` when a store is declared, nothing otherwise — and reads services through the **apply closure's own ctx**, so its capability boundary is the plugin's declared `inject` topology (the cordis property proxy applies natively; there is no assembly handle carrying a wider ctx). Its return value is plain data and callbacks only: the narrowed read/write face of the plugin's own services, cross-service orchestration (e.g. `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`), and per-(entry×session) assembly side effects. No hooks, no ReactNode producers, no whole-service objects — narrowing is the value: what a component can do is exactly the factory's return shape.
+An inject factory takes what its declarations earn it — `sessionId` for session slots, bound `actions` when a store is declared, nothing otherwise — and reads services through the **apply closure's own ctx**, so its capability boundary is the plugin's declared `inject` topology (the cordis property proxy applies natively; there is no assembly handle carrying a wider ctx). Its return value is plain data and callbacks, plus at most the reserved `hooks` compartment: a map of bare observable sources (getSnapshot+subscribe) the renderer binds into `use<Name>` selector hooks before the face reaches the component — the registrant-private twin of the provide channel's hooks compartment, for reactive facts too niche for the global standard kit (composer notices/lexicon, the settings nav rows). Components never receive the raw sources, so business code still contains no subscription machinery. Everything else stays plain: the narrowed read/write face of the plugin's own services, cross-service orchestration (e.g. `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`), and per-(entry×session) assembly side effects. No hand-made hooks, no ReactNode producers, no whole-service objects — narrowing is the value: what a component can do is exactly the factory's return shape.
 
 ### Data-boundary discipline
 
-Hooks are framework-made only: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats, implemented once with framework-guaranteed correctness; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own.
+Hooks are framework-made only: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` plus the hooks bound from provide contributions and inject `hooks` compartments — every one synthesized by the renderer's single binding machinery; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own.
 
 ### Tree context and the renderer seam
 
@@ -111,7 +111,7 @@ Render authority is enforceable rather than conventional: who renders what is a
 | Whitelist face objects (`ScopedSlots` + narrowing helpers) | With the whitelist already in the component's props type, the face is derivable by machinery; a mintable face object is a third authority surface with runtime-only checks |
 | Assembly handles carrying root ctx into inject | Bypasses declared inject topology — every factory could reach every service, so package.json dependency declarations stop meaning anything |
 | `children` as a key array | kind/scope are runtime dispatch data; SlotMap is erased, so an array forces a second spec-registration API — a definition API reborn |
-| Business-defined hooks via inject | Every plugin becomes its own subscription machine; the framework store seat carries the same data with one audited machine |
+| Business hand-made hooks / raw observables in component props | Every plugin becomes its own subscription machine; the inject `hooks` compartment carries the same facts through the one audited binding machinery |
 | Module-level store handles | A module-scope handle is a singleton across plugin reloads and test cases; the factory form scopes identity to apply/test invocation |
 | Components receiving the store instance | `update`/`set` in render code makes the mutation surface unauditable; declared actions keep "what can change" a register-site fact |
 | `FC` at the register position / inferring `I` from the component | FC statics generate covariant noise that rejects valid components; component-side inference absorbs props drift silently (see rulings above) |

+ 4 - 4
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md

@@ -45,7 +45,7 @@ ctx.slots.register({
 | 运行时 | `PropsRuntime<K>` | K 对应的 SlotMap entry | `OwnerOf<K>`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions`/`useWorkspaces` |
 | 子坑渲染 | `PropsRenderSlots<S>` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` |
 | store | `PropsStore<H>` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) |
-| 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) |
+| 业务 | `I` | inject 的返回类型 | 普通数据+回调;保留键 `hooks` 格的裸 observable 经绑定以 `use<Name>` 选择器 hook 到达(`InjectFace<I>`) |
 
 凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双向锁的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。
 
@@ -80,11 +80,11 @@ store 的 scope **从挂载 entry 的 scope 推导**(session 坑→每个会
 
 ### inject:注册方的业务面,立足自己的 ctx
 
-inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑(cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值只含普通数据与回调:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。
+inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑(cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值是普通数据与回调,至多外加保留键 `hooks` 格:一张裸 observable source(getSnapshot+subscribe)表,渲染器在业务面抵达组件前把每个 source 绑成 `use<Name>` 选择器 hook——即 provide 通道 hooks 格的注册方私有孪生,供太小众、不该进全局标准件的响应式事实(composer 的 notices/lexicon、settings 导航行)取用。组件永远收不到裸 source,业务代码因此仍零订阅机械。其余保持普通:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁手造 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。
 
 ### 数据界线纪律
 
-hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStore`、`renderSlot` 是仅有的五席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。
+hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStore`、`renderSlot` 五席,加上 provide 贡献与 inject `hooks` 格绑出的 hook——全部出自渲染器同一台绑定机械;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。
 
 ### 树上语境与渲染器安装缝
 
@@ -111,7 +111,7 @@ register 签名里的两条硬化裁定之所以存在,是因为显然的替
 | 白名单面对象(`ScopedSlots` + 收窄辅助件) | 白名单已在组件的 props 类型里,面可由机械推导;可铸造的面对象是第三个权威面,且只有运行时校验 |
 | 装配句柄把 root ctx 带进 inject | 绕开声明的 inject 拓扑——每个工厂都摸得到每个服务,package.json 的依赖声明就此失去意义 |
 | `children` 用键数组形 | kind/scope 是运行时分派数据;SlotMap 已被擦除,数组形必然逼出第二个 spec 注册 API——定义 API 复活 |
-| 业务经 inject 自定义 hook | 每个插件都变成自己的订阅机械;框架 store 席位用一台受审计的机械承载同样的数据 |
+| 业务手造 hook / 组件 props 里递裸 observable | 每个插件都变成自己的订阅机械;inject `hooks` 格让同样的事实走那一台受审计的绑定机械 |
 | 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 |
 | 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 |
 | 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) |

+ 2 - 2
packages/client/AGENTS.md

@@ -11,10 +11,10 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-
 1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`.
 2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'conversation.chat.toolview'`).
 3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
-4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
+4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
 5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
 6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
-7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hooks, no ReactNode producers, no whole-service objects. Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
+7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
 
 ## Export discipline (client plugin packages)
 

+ 3 - 1
packages/client/ui-conversation/src/client/apply.ts

@@ -135,13 +135,15 @@ export function apply(ctx: Context): void {
       'conversation.input.model': { kind: 'single', scope: 'session' },
     },
     inject: (sessionId: SessionId): ComposerBarInjected => {
+      const shell = inputHub.shell(sessionId)
       return {
-        keyboard: inputHub.keyboard(sessionId),
+        keyboard: shell,
         stop: () => {
           scopedConversation(sessions, sessionId).cancel().catch(() => {
             // Stop failure surfaces via snapshot.promptError; nothing to restore.
           })
         },
+        hooks: { notices: shell.notices, lexicon: shell.lexicon },
       }
     },
   }, InputBar)

+ 12 - 5
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -1,11 +1,11 @@
 /** Conversation slot declarations and their composed component props. */
 import type { ReactNode, RefObject } from 'react'
 import type {
-  MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
+  InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
 } from '@deepseek-ai/dsh-client-ui-slots'
-import type { ConversationSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
 import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
-import type { ComposerKeyboard, InputActions, InputState } from '../input/contract.ts'
+import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
 import type { createChatStore } from '../stores.ts'
 import type { CallId, SelectionTarget, ViewTab } from './views.ts'
 
@@ -220,6 +220,13 @@ export interface ComposerBarInjected {
   keyboard: ComposerKeyboard
   /** Cancel the in-flight turn. */
   stop: () => void
+  /** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
+  hooks: {
+    /** Latest surfaced notice (null after none; seq keys re-render of repeats). */
+    notices: ObservableSnapshot<InputNotice | null>
+    /** Hot plain-text reference lexicon for the decoration scan (decision 21). */
+    lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
+  }
 }
 
 /**
@@ -231,11 +238,11 @@ export interface InputControlOwnerProps {
   locked: boolean
 }
 
-/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share. */
+/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share (hooks compartment bound). */
 export type ComposerBarProps =
   PropsRuntime<'conversation.composer.bar'>
   & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
-  & ComposerBarInjected
+  & InjectFace<ComposerBarInjected>
 
 /**
  * Composer chain currency: what ConversationRoot dispatches at its

+ 1 - 5
packages/client/ui-conversation/src/client/input/contract.ts

@@ -5,7 +5,7 @@
  * conversation wiring layer alone sees the full SessionInput. InputMachine
  * (machine.ts) is package-private and never exported.
  */
-import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import type {
   ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
   ReferenceInsert, SubmitOutcome, TokenSpan,
@@ -77,8 +77,6 @@ export interface InputNotice {
  * satisfies it structurally.
  */
 export interface ComposerKeyboard {
-  /** Latest surfaced notice store (null after none). */
-  readonly notices: SnapshotStore<InputNotice | null>
   /** Live machine state for event-handler reads (render reads go through useInput). */
   readonly snapshot: InputState
   /** Draft write with the DOM-observed edit shape (narrows occurrence math). */
@@ -99,8 +97,6 @@ export interface ComposerKeyboard {
   space(): boolean
   /** Dismiss the popupSelect shell (any interaction outside the box). */
   dismissPopup(): void
-  /** 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). */

+ 6 - 13
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -1,11 +1,12 @@
 /** The default composer body: the 'conversation.composer.bar' slot entry
  * (decision 20). Machine state arrives through the standard provide channel
  * (useInput + inputActions); the keyboard/DOM command face and stop arrive
- * through this entry's own inject; layout-phase inputs (variant, placeholder,
+ * through this entry's own inject, whose hooks compartment binds
+ * useNotices/useLexicon; layout-phase inputs (variant, placeholder,
  * region-slot content) ride the owner props. Session facts
  * (running/removed/promptError) are self-selected via useSession. */
 
-import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
+import { useEffect, useRef, useState } from 'react'
 import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
 import clsx from 'clsx'
 import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -27,20 +28,12 @@ const READONLY_OPTIONS: readonly { id: string; label: string }[] = [
 ]
 
 export function InputBar({
-  useSession, useInput, inputActions, keyboard, stop, renderSlot,
+  useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon,
   variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
 }: InputBarProps) {
   const input = useInput(s => s)
-  const noticeStore = keyboard.notices
-  const notice = useSyncExternalStore(
-    (fn: () => void) => noticeStore.subscribe(fn),
-    () => noticeStore.getSnapshot(),
-  )
-  const lexiconStore = keyboard.lexicon
-  const lexicon = useSyncExternalStore(
-    (fn: () => void) => lexiconStore.subscribe(fn),
-    () => lexiconStore.getSnapshot(),
-  )
+  const notice = useNotices(s => s)
+  const lexicon = useLexicon(s => s)
   const promptError = useSession(s => s.promptError)
   const running = useSession(s => s.running)
   const disabled = useSession(s => s.removed)

+ 2 - 0
packages/client/ui-conversation/tests/input-bar.spec.tsx

@@ -91,6 +91,8 @@ function bench(over?: BenchOptions) {
     useInput: bindSnapshotSelector(shell.state),
     inputActions: shell.actions,
     keyboard: shell,
+    useNotices: bindSnapshotSelector(shell.notices),
+    useLexicon: bindSnapshotSelector(shell.lexicon),
     stop,
     renderSlot,
     variant: over?.variant ?? 'composer',

+ 2 - 0
packages/client/ui-conversation/tests/input-matrix.spec.tsx

@@ -42,6 +42,8 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
     useInput: bindSnapshotSelector(shell.state),
     inputActions: shell.actions,
     keyboard: shell,
+    useNotices: bindSnapshotSelector(shell.notices),
+    useLexicon: bindSnapshotSelector(shell.lexicon),
     renderSlot: (() => null) as InputBarProps['renderSlot'],
     stop: vi.fn(),
     variant: 'composer',

+ 2 - 0
packages/client/ui-conversation/tests/input-scenarios.spec.tsx

@@ -128,6 +128,8 @@ async function scopedBench(register?: (slash: SlashService) => void) {
     useInput: bindSnapshotSelector(shell.state),
     inputActions: shell.actions,
     keyboard: shell,
+    useNotices: bindSnapshotSelector(shell.notices),
+    useLexicon: bindSnapshotSelector(shell.lexicon),
     renderSlot: (() => null) as InputBarProps['renderSlot'],
     stop: vi.fn(),
     variant: 'composer',

+ 2 - 0
packages/client/ui-conversation/tests/skeleton.spec.tsx

@@ -118,6 +118,8 @@ function mount(
           useInput={useInput}
           inputActions={inputActions}
           keyboard={wiring}
+          useNotices={bindSnapshotSelector(wiring.notices)}
+          useLexicon={bindSnapshotSelector(wiring.lexicon)}
           stop={stop}
           renderSlot={(() => null) as InputBarProps['renderSlot']}
           {...bar}

+ 5 - 9
packages/client/ui-settings/src/client/SettingsRoot.tsx

@@ -7,10 +7,10 @@
  * aria-labelledby the title node; close: visually-hidden slot text). Modal
  * open state and the active section id are component-local viewing state.
  */
-import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from 'react'
+import { useCallback, useEffect, useId, useRef, useState } from 'react'
 import clsx from 'clsx'
 import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { SettingsRootComponentProps } from './contract/slots.ts'
+import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
 import css from './SettingsRoot.module.css'
 
 /** Nav glyph by section id; unknown ids fall back to the settings gear. */
@@ -20,7 +20,7 @@ function navIcon(id: string) {
 }
 
 type PanelProps = {
-  rows: ReturnType<SettingsRootComponentProps['sections']>
+  rows: readonly SettingsSectionRow[]
   renderSlot: SettingsRootComponentProps['renderSlot']
   onClose: () => void
 }
@@ -92,18 +92,14 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
  * @returns the settings shell element tree.
  */
 export function SettingsRoot(props: SettingsRootComponentProps) {
-  const { wide, subscribeSections, sectionsVersion, sections, renderSlot } = props
+  const { wide, useSections, renderSlot } = props
   const [open, setOpen] = useState(false)
   const close = useCallback(() => { setOpen(false) }, [])
 
   // The ledger tick keeps the nav rows fresh: registrants re-register with
   // freshly localized text on locale change, and the trigger/header/close
   // seats re-render through their own outlets' subscriptions.
-  // uSES over the ledger version: same-version notifications dedupe to no
-  // render, and a registration landing between render and effect
-  // subscription cannot be missed.
-  useSyncExternalStore(subscribeSections, sectionsVersion)
-  const rows = sections()
+  const rows = useSections(s => s)
 
   return (
     <>

+ 18 - 12
packages/client/ui-settings/src/client/contract/slots.ts

@@ -7,7 +7,7 @@
  * setting never means editing the shell; copy that belongs to no single
  * feature (chrome, the General section) is owned by ui-settings-general.
  */
-import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 // Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
 // into every program that sees this contract.
 import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
@@ -72,26 +72,32 @@ export interface SettingsSectionOwnerProps {
   children?: never
 }
 
+/** One nav row projected from a settings.section registration's options. */
+export interface SettingsSectionRow {
+  id: string
+  order: number
+  label: string
+}
+
 /**
  * Registrant-private injected share of the settings shell (assembled in
- * apply): ledger projections only — the shell reads no locale state.
+ * apply): the ledger's nav-row projection as a hooks-compartment source —
+ * the shell reads no locale state and subscribes through the bound hook.
  */
 export type SettingsRootInjected = {
-  /** Read the settings.section ledger version (nav invalidation). */
-  sectionsVersion: () => number
-  /** Subscribe to settings.section ledger changes. */
-  subscribeSections: (listener: () => void) => () => void
-  /** Project the settings.section ledger into nav rows (id/order/label). */
-  sections: () => readonly { id: string; order: number; label: string }[]
+  hooks: {
+    /** settings.section ledger projected into ordered nav rows. */
+    sections: HostObservable<readonly SettingsSectionRow[]>
+  }
 }
 
 /**
  * Full component props of the settings shell root: the sidebar owner share
- * (wide/rail state) plus the declared render shares and the injected face.
- * No store is registered — modal open state and active section id are
- * component-local viewing state.
+ * (wide/rail state) plus the declared render shares and the injected face
+ * (hooks compartment bound to useSections). No store is registered — modal
+ * open state and active section id are component-local viewing state.
  */
 export type SettingsRootComponentProps =
   PropsRuntime<'sidebar.settings'>
   & PropsRenderSlots<'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section'>
-  & SettingsRootInjected
+  & InjectFace<SettingsRootInjected>

+ 26 - 12
packages/client/ui-settings/src/client/index.ts

@@ -10,12 +10,12 @@
  */
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
 import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
-import type { SettingsRootInjected } from './contract/slots.ts'
+import type { SettingsRootInjected, SettingsSectionRow } from './contract/slots.ts'
 import { SettingsRoot } from './SettingsRoot.tsx'
 
 export type {
   SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
-  SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
+  SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps,
 } from './contract/slots.ts'
 
 /**
@@ -32,17 +32,31 @@ export const inject = ['slots']
  * @param ctx - client root context.
  */
 export function apply(ctx: ClientContext): void {
+  // Ledger → nav-row projection as an observable source (uSES contract:
+  // getSnapshot returns the cached rows until the ledger version moves).
+  let rowsVersion = -1
+  let rows: readonly SettingsSectionRow[] = []
   const injected = (): SettingsRootInjected => ({
-    sectionsVersion: () => ctx.slots.getVersion('settings.section'),
-    subscribeSections: listener => ctx.slots.subscribe('settings.section', listener),
-    sections: () => ctx.slots.entries('settings.section')
-      .map(e => ({
-        /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
-        id: e.options.id ?? '',
-        order: e.options.order ?? 0,
-        label: e.options.label ?? '',
-      }))
-      .sort((a, b) => a.order - b.order),
+    hooks: {
+      sections: {
+        getSnapshot: () => {
+          const version = ctx.slots.getVersion('settings.section')
+          if (version !== rowsVersion) {
+            rowsVersion = version
+            rows = ctx.slots.entries('settings.section')
+              .map(e => ({
+                /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
+                id: e.options.id ?? '',
+                order: e.options.order ?? 0,
+                label: e.options.label ?? '',
+              }))
+              .sort((a, b) => a.order - b.order)
+          }
+          return rows
+        },
+        subscribe: listener => ctx.slots.subscribe('settings.section', listener),
+      },
+    },
   })
   ctx.effect(() => {
     const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () =>

+ 8 - 5
packages/client/ui-settings/tests/apply.spec.ts

@@ -60,22 +60,25 @@ describe('ui-settings apply', () => {
     const b = await bench()
     declare(b.slots)
     await b.ctx.plugin({ inject: [...inject], apply }).await()
-    const injected = injectedOf(b.slots)
+    const { sections } = injectedOf(b.slots).hooks
     // The shell ships no sections of its own — registrants fill the ledger.
-    expect(injected.sections()).toEqual([])
+    expect(sections.getSnapshot()).toEqual([])
     b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
     // No order and no label: both projection defaults apply.
     b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
-    expect(injected.sections()).toEqual([
+    const rows = sections.getSnapshot()
+    expect(rows).toEqual([
       { id: 'a', order: 0, label: '' },
       { id: 'z', order: 20, label: 'Z' },
     ])
-    expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section'))
+    // Snapshot identity is stable until the ledger moves (uSES contract).
+    expect(sections.getSnapshot()).toBe(rows)
     const listener = vi.fn()
-    const off = injected.subscribeSections(listener)
+    const off = sections.subscribe(listener)
     b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null)
     await Promise.resolve()
     expect(listener).toHaveBeenCalled()
+    expect(sections.getSnapshot()).not.toBe(rows)
     off()
   })
 

+ 11 - 8
packages/client/ui-settings/tests/settings-root.spec.tsx

@@ -1,5 +1,6 @@
 // @vitest-environment jsdom
 import { afterEach, describe, expect, it, vi } from 'vitest'
+import { useEffect, useState } from 'react'
 import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
 import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts'
 import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
@@ -22,9 +23,9 @@ function mount({
     { id: 'models', order: 10, label: 'Models' },
   ],
 }: { wide?: boolean; rows?: Row[] } = {}) {
-  // Mutable row store standing in for the ledger; bump() plays a change.
+  // Mutable row source standing in for the bound useSections hook; bump()
+  // plays a ledger change through the same observable contract.
   let current = rows
-  let version = 0
   const listeners = new Set<() => void>()
   const renderSlot = vi.fn(
     ((key: string, _owner: unknown, opts?: { only?: string }) => {
@@ -38,19 +39,21 @@ function mount({
     useSessions: unusedHook,
     useWorkspaces: unusedHook,
     wide,
-    sectionsVersion: () => version,
-    subscribeSections: (listener) => {
-      listeners.add(listener)
-      return () => { listeners.delete(listener) }
+    useSections: (select) => {
+      const [, force] = useState(0)
+      useEffect(() => {
+        const listener = () => { force(n => n + 1) }
+        listeners.add(listener)
+        return () => { listeners.delete(listener) }
+      }, [])
+      return select(current)
     },
-    sections: () => current,
     renderSlot,
   }
   const view = render(<SettingsRoot {...props} />)
   const bump = (next: Row[]) => {
     act(() => {
       current = next
-      version += 1
       for (const fn of [...listeners]) fn()
     })
   }

+ 33 - 3
packages/client/ui-slots/src/index.ts

@@ -14,6 +14,7 @@
  * consumer merges keys in and the intersection is what keeps them string-typed.
  * The rule fires on the empty-map view, not on real redundancy. */
 import type { ReactNode } from 'react'
+import type { HostObservable } from './renderer.ts'
 import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl } from './store.ts'
 
 export * from './store.ts'
@@ -214,11 +215,40 @@ export type PropsRenderSlots<S extends keyof SlotMap & string> = {
  */
 export type SlotComponent<P> = (props: P) => ReactNode
 
+/**
+ * Registrant hooks compartment: bare observable sources (getSnapshot +
+ * subscribe pairs) supplied under the reserved `hooks` key of an inject
+ * face. The registrant-private twin of the `sessions.provide` hooks
+ * compartment: the renderer binds each source into a `use<Name>` selector
+ * hook, so the sources never reach the component and plugin-private reactive
+ * facts ride the same subscription machinery as the standard kit instead of
+ * hand-rolled component subscriptions.
+ */
+export type HooksSources = Record<string, HostObservable<unknown>>
+
+/**
+ * Selector-hook share synthesized from a hooks compartment: each source
+ * `name` becomes a `use<Name>` selector hook over its snapshot type.
+ */
+export type PropsHooks<HS extends HooksSources> = {
+  [N in keyof HS & string as `use${Capitalize<N>}`]:
+  SnapshotSelectorHook<HS[N] extends HostObservable<infer T> ? T : never>
+}
+
+/**
+ * The component-side view of an inject face: the reserved `hooks`
+ * compartment (when declared) arrives as bound `use<Name>` selector hooks;
+ * every other member passes through verbatim.
+ */
+export type InjectFace<I extends object> =
+  I extends { hooks: infer HS extends HooksSources } ? Omit<I, 'hooks'> & PropsHooks<HS> : I
+
 /**
  * The four-share component props intersection: runtime share (SlotMap) +
  * child-render share (children declaration) + store share (declared handle) +
- * the registrant's injected business face. Each share derives from its single
- * source of truth; components reference this composition, never re-type it.
+ * the registrant's injected business face (its hooks compartment bound, see
+ * {@link InjectFace}). Each share derives from its single source of truth;
+ * components reference this composition, never re-type it.
  */
 export type ComposedProps<
   K extends keyof SlotMap & string,
@@ -226,7 +256,7 @@ export type ComposedProps<
   H,
   I extends object,
   M = never,
-> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & I & MatchedShare<SlotMap[K], M>
+> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M>
 
 /**
  * Inject factory parameter list, derived from the registration's declaration:

+ 22 - 3
packages/client/web-react/src/scoped-slots.tsx

@@ -5,8 +5,8 @@
 import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
 import {
   SlotOwnershipError, StaleAuthorizationError,
-  type ChainRenderOpts, type RenderOpts, type SessionMaybeProvideInfo, type SessionProvideInfo,
-  type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
+  type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo,
+  type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
 } from '@deepseek-ai/dsh-client-ui-slots'
 import {
   HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
@@ -96,7 +96,26 @@ function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined
   const args: unknown[] = []
   if (info !== undefined) args.push(info.sessionId)
   if (actions !== undefined) args.push(actions)
-  return (inject as (...args: unknown[]) => InjectedProps)(...args)
+  return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args))
+}
+
+/**
+ * Bind an inject face's reserved `hooks` compartment (bare observable
+ * sources, see HooksSources) into `use<Name>` selector hooks — the
+ * registrant-private twin of the provide-bundle binding in standardKit.
+ * Runs once per cached inject result; hook identity rides observableHook's
+ * per-source cache.
+ */
+function bindInjectHooks(face: InjectedProps): InjectedProps {
+  const sources = face['hooks']
+  if (sources === undefined) return face
+  const { hooks: _hooks, ...rest } = face
+  const bound: InjectedProps = rest
+  for (const [name, source] of Object.entries(sources as Record<string, HostObservable<unknown>>)) {
+    const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
+    bound[hookName] = observableHook(source)
+  }
+  return bound
 }
 
 function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps {

+ 19 - 0
packages/client/web-react/tests/scoped-slots.spec.tsx

@@ -748,6 +748,25 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
     expect(inject).toHaveBeenCalledWith()
   })
 
+  it('binds the inject hooks compartment into use<Name> selector hooks (sources never reach the component)', () => {
+    const h = makeHost()
+    h.declare('k.single', SINGLE_ROOT)
+    const badge = observable('cold')
+    const seen: Record<string, unknown>[] = []
+    h.add('k.single', {
+      component: (props: { useBadge?: <S>(sel: (s: string) => S) => S; hooks?: unknown; plain?: string }) => {
+        seen.push({ hooks: props.hooks, plain: props.plain, read: props.useBadge!(s => s) })
+        return null
+      },
+      inject: () => ({ plain: 'kept', hooks: { badge } }),
+    })
+    mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
+    // The raw compartment is consumed by the binding; the plain member passes through.
+    expect(seen.at(-1)).toEqual({ hooks: undefined, plain: 'kept', read: 'cold' })
+    act(() => { badge.set('hot') })
+    expect(seen.at(-1)!['read']).toBe('hot')
+  })
+
   it('session inject receives sessionId and caches per (entry x session): switch-back reuses', () => {
     const h = makeHost()
     h.declare('k.session', SINGLE_SESSION)