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.
This commit is contained in:
20 files changed
+185
-89
No files matched your search
+3
-3
@@ -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
|
||||
@@ -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
@@ -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 漂移(见上文裁定) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
@@ -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, () =>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user