diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml index eacbd89847..1604914ac0 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md index 617524475f..e88361701f 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md +++ b/.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` | SlotMap entry for K | `OwnerOf` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions`/`useWorkspaces` | | child render | `PropsRenderSlots` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` | | store | `PropsStore` | 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` selector hooks (`InjectFace`) | `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` 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) | diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md index 52edea30ac..90473861c1 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md @@ -45,7 +45,7 @@ ctx.slots.register({ | 运行时 | `PropsRuntime` | K 对应的 SlotMap entry | `OwnerOf`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions`/`useWorkspaces` | | 子坑渲染 | `PropsRenderSlots` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` | | store | `PropsStore` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) | -| 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) | +| 业务 | `I` | inject 的返回类型 | 普通数据+回调;保留键 `hooks` 格的裸 observable 经绑定以 `use` 选择器 hook 到达(`InjectFace`) | 凡声明 `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` 选择器 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 漂移(见上文裁定) | diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index f3a525fe70..4783b79a6a 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 09afe6d9e879ae7529d309c3b5e656be849fa543 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 4d45d74c2e7c34601a5229fc0fc0780a23ec6fd5 +2026-07-25-web-client-session-scope-and-provide-channel.md: 4496e3786ed4adb6e60dfd5cfad72e989657f649 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 768dada95aacdb358115d496f45e7fc0eece0151 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 09afe6d9e8..4496e3786e 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -90,7 +90,7 @@ The sole provisioning path by which session slot components fetch their own sess Slot scope is the closed set `root | session-maybe | session`: - `root` receives only the global standard kit, with no session identity or provisioning. -- `session-maybe` follows the current session, but the component instance does not change key when the id appears, disappears, or changes; with no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates, while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session. +- `session-maybe` follows the current session, but the component instance does not change key when the id appears, disappears, or changes; with no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session. - `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store. `conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch; `conversation.session` carries only the strict-session header/view, while the composer and every input slot also stay strict `session`. With no session, the composer stack places the presentation-only `DisabledInputBar` directly; once a session appears, the input body is swapped for the strictly bound InputBar; the textarea may be rebuilt, while the Hero and the layout skeleton are not. The blank → engaging/active transition stays inside the same strict-session subtree, and the InputBar is never rebuilt on a phase flip. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 4d45d74c2e..768dada95a 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -90,7 +90,7 @@ session slot 组件「自己拿 session 数据」的唯一供数路径。插件 slot scope 是闭集 `root | session-maybe | session`: - `root` 只拿全局标准件,不接收 session 身份或供数。 -- `session-maybe` 跟随 current session,但组件实例不因 id 有无或切换而换 key;无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 驱动这条更新,`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状。 +- `session-maybe` 跟随 current session,但组件实例不因 id 有无或切换而换 key;无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动与 provider 名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的 hook/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状。 - `session` 保证 `sessionId`、所有 hook source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。 `conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;`conversation.session` 只承载严格 session 的 header/view,composer 与各输入 slot 也保持严格 `session`。无 session 时 composer stack 直接放纯展示的 `DisabledInputBar`,session 出现后把输入体换成严格绑定的 InputBar;textarea 允许重建,Hero 与布局骨架不重建。blank → engaging/active 仍在同一严格 session subtree 内,InputBar 不因 phase 翻转而重建。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml index d636aab9ff..29f56666b2 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.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-25-web-command-surfaces-and-assembly.md: 5188e8c17b31157b1c03203a8d7ba2d8e6a1496b -2026-07-25-web-command-surfaces-and-assembly.zh.md: 0134cc10cf4f49b7719d6a0dacb239389776d6ed +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md +2026-07-25-web-command-surfaces-and-assembly.md: 4c4a400abab940baebc1699fc15b709419865f0c +2026-07-25-web-command-surfaces-and-assembly.zh.md: c0acd1ecc5998a0ec488a1f13ed99ae4a93a240b diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md index 5188e8c17b..4c4a400aba 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md @@ -28,8 +28,8 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx ### Reference sources (seeing only projections plus their own apply closures, on the root ctx) -- **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm). No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association). -- **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot (the model-side representation awaits its business workstream). +- **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm), and `subscribeLexicon` notifies per-session listeners on settle and on invalidation. No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association). +- **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot and `subscribeLexicon` forwards the list store's change feed (the model-side representation awaits its business workstream). ### Fixture command routing and assembly diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md index 0134cc10cf..c0acd1ecc5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md @@ -28,8 +28,8 @@ Status: implemented ### 引用源(只见投影 + 自家 apply 闭包的 root ctx) -- **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,决策 21);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`)。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通 prompt 走(命令平面之外;tool-skill 不变,session-prefix 目录提供协作关联)。 -- **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生(模型侧表示待业务立项)。 +- **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,决策 21);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`),`subscribeLexicon` 在 settle 与失效时按会话通知监听者。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通 prompt 走(命令平面之外;tool-skill 不变,session-prefix 目录提供协作关联)。 +- **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生,`subscribeLexicon` 转发 list store 的变更通道(模型侧表示待业务立项)。 ### fixture 命令路由与装配 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 0249baff80..121879629c 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.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-25-web-input-machine-and-slash-pipeline.md: acbd132a5fdb97a4098064aae689dfca604ad4b7 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 158650a41b47f98037a1b3e610d9294694c55a8c +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +2026-07-25-web-input-machine-and-slash-pipeline.md: 8cf3be7b3b7579d0c37898a58fb0ab4990fd71bf +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: b1488893558d8b2bf9c104faca968435d46a9640 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index acbd132a5f..8cf3be7b3b 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -81,10 +81,10 @@ A trigger/menu/pick pipeline with zero knowledge of "commands": skill/@subagent references skip the placeholder + occurrence identity chain — a pick inserts the literal `/name ` `@name ` text straight into the draft, with the chip visual purely derived: - PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes. -- Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the controller aggregates it into the `lexicon()` public surface. +- Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional `subscribeLexicon?(session, listener)` hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its `lexicon` snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast. - `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit gets the `.textRef` mark (a pure range highlight on the backdrop, same as hlToken); an edit breaking the match shape simply disappears on the next scan. - Sending is the literal text (no more `` serialization); on the bubble side MessageItem decorates both shapes (the legacy `` tag + plain-text tokens). -- The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Known limitation kept as-is: with the lexicon not warm at paste / cold start there is no decoration — it lights up only after typing `/` opens the menu once. +- The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Decoration reactivity: InputBar subscribes to the shell's lexicon source (uSES), so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render. ### Per-session provide contributions and the private keyboard surface diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index 158650a41b..b148889355 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -81,10 +81,10 @@ occurrence 表与 chip 三投影: skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 `/name ` `@name ` 原文插进 draft,chip 视觉纯派生: - PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同契约:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。 -- source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);controller 聚合为 `lexicon()` 公面。 +- source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` snapshot store(每次 source 通知重拉);scope 出生后才注册的 source 由 service 广播给活 controller,补 warm 并并入名录。 - `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即 `.textRef` mark(backdrop 纯 range 高亮,同 hlToken);编辑破坏匹配形状下次扫描自然消失。 - 发送即原文(不再 `` 序列化);气泡侧 MessageItem 双形状装饰(legacy `` 标签 + 纯文本 token)。 -- 旧 occurrence/paste/serialize 链全部保留在盘未删(additive;删除另成将来一刀)。已知局限维持现状:粘贴/冷启动时 lexicon 未热不装饰,输 `/` 开一次菜单后才亮。 +- 旧 occurrence/paste/serialize 链全部保留在盘未删(additive;删除另成将来一刀)。装饰响应性:InputBar 以 uSES 订阅 shell 的 lexicon source,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。 ### per-session 供数贡献与键盘私面 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 86a857cd17..ca0e9b82fc 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -658,7 +658,7 @@ Applies one command claim to the scoped Input. Dispatched with the session's sco 'slash/input-begin-command'(request: BeginCommandRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:220`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:230`](../../packages/client/ui-slash/src/types.ts) ### `slash/input-consume-token` — bail @@ -674,7 +674,7 @@ Consumes one command token after business success (popup settle / menu-pick exec 'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:234`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:244`](../../packages/client/ui-slash/src/types.ts) ### `slash/input-insert-reference` — bail @@ -690,7 +690,7 @@ Inserts one reference into the scoped Input (same carrier routing and applied-tr 'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:227`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:237`](../../packages/client/ui-slash/src/types.ts) ### `slash/input-insert-text` — bail @@ -707,7 +707,7 @@ Replaces the trigger token span with literal text — the plain-text reference p 'slash/input-insert-text'(request: InsertTextRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:242`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:252`](../../packages/client/ui-slash/src/types.ts) ## `subagent/*` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 18ca96e1b3..3a72d5c0f9 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -35,10 +35,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:220`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:234`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:242`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:252`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:120`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 3728641560..a7d80b3232 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -11,10 +11,21 @@ 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: `..` (e.g. `'conversation.chat.toolview'`). 3. **Component props are the four shares, all derived**: `PropsRuntime` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots` (children keys) & `PropsStore` (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` 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`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for. + +## Reactive read and contract-currency discipline + +How live data reaches render code, and what may cross a business boundary: + +1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes. +2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`. +3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration. +4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are grandfathered and get migrated to slots progressively). +5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves). +6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering. ## Export discipline (client plugin packages) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 3fa9c934f3..32d4fdf417 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/client/runtime/README.md -README.md: 81261945cb2fd8b15f7c2f15cb1ae0b8e9928499 -README.zh.md: cbbf6eded4a5375223791275f26f3bc7b6553200 +README.md: 16c1124ec812b9f030ce8266a16cdb8f5db0e6cc +README.zh.md: a3d2a2dfdd1662afee65ec45e26b1ef1029f44b5 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 81261945cb..16c1124ec8 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -39,5 +39,5 @@ Changing the target can change or invalidate provider-side cache reuse; this pac ## Known Limitations and Deferred Work - **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project. -- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`provideInfo()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land. +- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land. - **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem). diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index cbbf6eded4..a3d2a2dfdd 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -39,5 +39,5 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 已知限制与暂缓事项 - **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。 -- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`provideInfo()`/`binding()`/`scope()`)只是纯寻址,可安全用于渲染。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。 +- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。 - **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。 diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index ec8ddc2354..9754c87345 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -151,6 +151,13 @@ export class SessionsService { readonly list: SnapshotStore /** The object-layer instance cluster and frame dispatch entry. */ private readonly manager: SessionManager + /** + * Atomic current-session provide projection: selection changes and + * provider-roster changes publish through this one source (the renderer + * host's `sessions.provide` feed), so a roster change under a stable + * current id republishes the bundle instead of stranding mounted entries. + */ + readonly currentProvideInfo: HostObservable /** * Persisted selection cell (the durable half of `list.current`). Private on @@ -167,6 +174,10 @@ export class SessionsService { private readonly providers: SessionProvideDescriptor[] = [] /** Static no-session projection, rebuilt only when the provider roster changes. */ private maybeInfo: SessionMaybeProvideInfo + /** Latest published {@link SessionsService.currentProvideInfo} bundle (identity comparison dedupes republish). */ + private currentProvideInfoSnapshot: SessionMaybeProvideInfo + /** currentProvideInfo subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */ + private readonly currentProvideInfoListeners = new Set<() => void>() /** * The staged session id — follows `list.current` exactly, holding its last * defined value across masked gaps (a transiently absent selection blanks @@ -198,7 +209,11 @@ export class SessionsService { // dedicated code path. Safe to run synchronously inside the store notify: // the follower writes no list state — session.open()'s synchronous prefix // touches only session-side state and its own microtask-batched notifier. - this.list.subscribe(() => { this.followCurrent() }) + // The current-provide projection follows the same current writes. + this.list.subscribe(() => { + this.followCurrent() + this.updateCurrentProvideInfo() + }) // The runtime's own contribution comes first: useSession rides the same // provide channel every plugin uses (no renderer special case). this.providers.push({ @@ -206,6 +221,14 @@ export class SessionsService { resolve: binding => ({ hooks: { session: binding.session } }), }) this.maybeInfo = this.materializeMaybeProvideInfo() + this.currentProvideInfoSnapshot = this.maybeInfo + this.currentProvideInfo = { + getSnapshot: () => this.currentProvideInfoSnapshot, + subscribe: (fn) => { + this.currentProvideInfoListeners.add(fn) + return () => { this.currentProvideInfoListeners.delete(fn) } + }, + } rootCtx.reflect.provide('sessions', this, undefined) } @@ -238,6 +261,30 @@ export class SessionsService { for (const record of this.scopes.values()) { record.provideInfo = this.materializeProvideInfo(record.binding) } + this.updateCurrentProvideInfo() + } + + /** + * Re-derive the current selection's provide bundle and publish it when it + * changed. Bundles are identity-stable per (scope, roster) + * materialization, so an identity compare is exact; synchronous notify — + * both call sites (list.subscribe, provide()) already sit behind their own + * batching or registration edges. + */ + private updateCurrentProvideInfo(): void { + const next = this.maybeProvideInfo(this.list.getSnapshot().current) + if (next === this.currentProvideInfoSnapshot) return + this.currentProvideInfoSnapshot = next + for (const fn of [...this.currentProvideInfoListeners]) { + try { + fn() + } catch (error) { + // Contain subscriber failures: this notify runs inside the list + // notification, where a throwing render-side subscriber would starve + // later listeners and abort the projection pass that scheduled it. + console.error('sessions.currentProvideInfo subscriber failed:', error) + } + } } /** Build the static no-session kit and reject duplicate declared names. */ @@ -404,25 +451,21 @@ export class SessionsService { } /** - * Resolve the render-layer standard-props bundle (SessionProvider's feed - * through the renderer host; ctx never enters the render layer). Pure - * resolution — render-safe: SessionProvider calls this during render, so no - * staging, no window side effects (StrictMode double-invokes and concurrent - * discarded passes must stay free). - * @param id - session id. - * @returns the provide info, or undefined for a session neither listed nor already scoped. + * Resolve one session's render-layer standard-props bundle (ctx never + * enters the render layer; the renderer subscribes to + * {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe: + * no staging, no window side effects (StrictMode double-invokes and + * concurrent discarded passes must stay free). */ - provideInfo(id: string): SessionProvideInfo | undefined { + private provideInfo(id: string): SessionProvideInfo | undefined { return this.resolve(id as SessionId)?.provideInfo } /** * Resolve the current-session-optional standard kit. Unknown or absent ids * return the static no-session projection rather than removing hook props. - * @param id - current session id, when selected. - * @returns a definite or no-session provide bundle. */ - maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { + private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo } diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index 74af31502a..d18377e052 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -246,13 +246,6 @@ export class SlotsService extends Service { if (workspaces === undefined) { throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first") } - // Identity-stable view: current rides the list snapshot (arbitrated), but - // the provider consumes it as its own observable; one cached object keeps - // the renderer's per-source hook cache stable. - const current = { - getSnapshot: () => sessions.list.getSnapshot().current as string | undefined, - subscribe: (fn: () => void) => sessions.list.subscribe(fn), - } this._host = { subscribe: (key, fn) => this._core.subscribe(key, fn), getVersion: key => this._core.getVersion(key), @@ -263,9 +256,7 @@ export class SlotsService extends Service { entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey), sessions: { list: sessions.list, - current, - provideInfo: id => sessions.provideInfo(id), - maybeProvideInfo: id => sessions.maybeProvideInfo(id), + provideInfo: sessions.currentProvideInfo, }, workspaces: { list: workspaces.list }, } diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 44ab4ffb4f..45539d3b99 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -79,7 +79,8 @@ describe('scope tree', () => { expect(scopeOf(scoped as Context)).toBe('s1') expect(scopeOf(b.ctx)).toBeUndefined() const binding = b.svc.binding(sid('s1')) - expect(binding?.session).toBe(b.svc.provideInfo('s1')?.hooks['session']) + b.svc.open(sid('s1')) + expect(binding?.session).toBe(b.svc.currentProvideInfo.getSnapshot().hooks['session']) expect(b.svc.binding(sid('s1'))).toBe(binding) expect(binding?.ctx).toBe(scoped) }) @@ -183,24 +184,82 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s }) describe('cell (render-layer session kit)', () => { - it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => { + it('resolves an identity-stable {sessionId, session} cell through the current projection', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) - const info = b.svc.provideInfo('s1') - expect(info).toBeDefined() - expect(info?.sessionId).toBe('s1') + b.svc.open(sid('s1')) + const info = b.svc.currentProvideInfo.getSnapshot() + expect(info.sessionId).toBe('s1') // The bundle carries bare observables; hook binding happens in React. - expect(info?.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session) - expect(b.svc.provideInfo('s1')).toBe(info) - expect(b.svc.provideInfo('ghost')).toBeUndefined() + expect(info.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session) + // Re-staging the same id republishes nothing: identity holds. + b.svc.open(sid('s1')) + expect(b.svc.currentProvideInfo.getSnapshot()).toBe(info) }) - it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => { + it('currentProvideInfo follows selection: absent projection ↔ definite bundle, notified on each move', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }, { id: 's2' }]) + const absent = b.svc.currentProvideInfo.getSnapshot() + expect(absent.sessionId).toBeUndefined() + expect(Object.hasOwn(absent.hooks, 'session')).toBe(true) + const notified = vi.fn() + b.svc.currentProvideInfo.subscribe(notified) + b.svc.open(sid('s1')) + const s1Bundle = b.svc.currentProvideInfo.getSnapshot() + expect(s1Bundle.sessionId).toBe('s1') + expect(s1Bundle.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session) + expect(notified).toHaveBeenCalledTimes(1) + b.svc.open(sid('s2')) + const s2Bundle = b.svc.currentProvideInfo.getSnapshot() + expect(s2Bundle.sessionId).toBe('s2') + expect(s2Bundle).not.toBe(s1Bundle) + expect(notified).toHaveBeenCalledTimes(2) + b.svc.clear() + await Promise.resolve() // clearSelection projects through the manager notifier + expect(b.svc.currentProvideInfo.getSnapshot().sessionId).toBeUndefined() + }) + + it('a provider roster change under a stable current id republishes the bundle', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }]) + b.svc.open(sid('s1')) + const before = b.svc.currentProvideInfo.getSnapshot() + const notified = vi.fn() + b.svc.currentProvideInfo.subscribe(notified) + const source = { getSnapshot: () => 'live', subscribe: () => () => {} } + const dispose = b.svc.provide({ + hooks: ['extra'], + props: ['marker'], + resolve: () => ({ hooks: { extra: source }, props: { marker: 7 } }), + }) + const added = b.svc.currentProvideInfo.getSnapshot() + expect(added).not.toBe(before) + expect(added).toMatchObject({ sessionId: 's1', props: { marker: 7 } }) + expect(added.hooks['extra']).toBe(source) + expect(notified).toHaveBeenCalledTimes(1) + dispose() + const removed = b.svc.currentProvideInfo.getSnapshot() + expect(removed).not.toBe(added) + expect(Object.hasOwn(removed.hooks, 'extra')).toBe(false) + expect(notified).toHaveBeenCalledTimes(2) + }) + + it('an unsubscribed currentProvideInfo listener stops receiving notifications', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }]) + const notified = vi.fn() + const off = b.svc.currentProvideInfo.subscribe(notified) + off() + b.svc.open(sid('s1')) + expect(notified).not.toHaveBeenCalled() + }) + + it('binding() is pure resolution: no staging, no deferred sweep', async () => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) b.svc.open(sid('s1')) // staged - b.svc.provideInfo('s2') // resolution only — must NOT move the stage - b.svc.binding(sid('s2')) + b.svc.binding(sid('s2')) // resolution only — must NOT move the stage await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives expect(b.svc.scope(sid('s1'))).toBeDefined() }) @@ -211,7 +270,6 @@ describe('cell (render-layer session kit)', () => { const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history') // Resolution is addressing, not staging: no window pull. b.svc.scope(sid('s1')) - b.svc.provideInfo('s1') b.svc.binding(sid('s1')) expect(historyCalls()).toHaveLength(0) b.svc.open(sid('s1')) diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index 97bcb50f0a..07e03b6e9d 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -97,18 +97,13 @@ function fakeWorkspaces() { return { list: { getSnapshot: () => state, subscribe: () => () => undefined } } } -/** Minimal sessions face for the host seam (list observable + provide bundle). */ +/** Minimal sessions face for the host seam (list observable + current provide projection). */ function fakeSessions() { const state = { ids: [], byId: {}, current: undefined as string | undefined } + const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} } return { list: { getSnapshot: () => state, subscribe: () => () => undefined }, - provideInfo: (id: string) => (id === 'known' - ? { - sessionId: id, - hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } }, - props: {}, - } - : undefined), + currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => undefined }, } } @@ -232,13 +227,11 @@ describe('host face', () => { expect(host.entriesOf('t.host')).toHaveLength(0) }) - it('exposes sessions list/current/provideInfo (current riding the list snapshot)', async () => { + it('exposes the session list and the atomic current provide projection', async () => { const bench = await boot() const host = captureHost(bench) expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] }) - expect(host.sessions.current.getSnapshot()).toBeUndefined() - expect(host.sessions.provideInfo('known')).toMatchObject({ sessionId: 'known' }) - expect(host.sessions.provideInfo('ghost')).toBeUndefined() + expect(host.sessions.provideInfo.getSnapshot()).toMatchObject({ sessionId: undefined }) }) it('exposes the independent Workspace list source', async () => { diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 789f86aeb6..62801ca0b8 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/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) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 44f337d61c..7a99323826 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/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 + /** Hot plain-text reference lexicon for the decoration scan (decision 21). */ + lexicon: ObservableSnapshot> + } } /** @@ -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 /** * Composer chain currency: what ConversationRoot dispatches at its diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 8a4d2905db..4454662cd6 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -77,8 +77,6 @@ export interface InputNotice { * satisfies it structurally. */ export interface ComposerKeyboard { - /** Latest surfaced notice store (null after none). */ - readonly notices: SnapshotStore /** 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 lexicons for the decoration scan (decision 21; empty Map without a pipeline). */ - lexicon(): ReadonlyMap<'/' | '@', readonly string[]> } /** One queued-message row projected from the session/queued frames (T9 supplies the store). */ diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index f3f6dd7451..d6b2fde80a 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -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> = { + getSnapshot: () => this.deps.slash?.()?.lexicon.getSnapshot() ?? EMPTY_LEXICON, + subscribe: fn => this.deps.slash?.()?.lexicon.subscribe(fn) ?? (() => {}), } /** diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index f2313d798b..a844c18a72 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/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,15 +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 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) @@ -244,7 +242,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 diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 504d0ec8c6..885cdd2523 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -87,12 +87,13 @@ async function bench() { } } const providers: TestProvider[] = [] + const absentInfo = { sessionId: undefined, hooks: {}, props: {} } const sessionsFake = { list: listStore, binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }), scope: (id: SessionId) => mint(id), provideInfo: () => undefined, - maybeProvideInfo: () => ({ hooks: {}, props: {} }), + currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} }, scopeOf, sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake), diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 36a25c5b34..dab04e94c7 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -32,12 +32,13 @@ async function bench() { current: undefined, phase: 'ready', }) + const absentInfo = { sessionId: undefined, hooks: {}, props: {} } const sessionsFake = { list: listStore, binding: vi.fn(), scope: () => undefined, provideInfo: () => undefined, - maybeProvideInfo: () => ({ hooks: {}, props: {} }), + currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, provide: vi.fn(() => () => {}), create: vi.fn(), open: vi.fn(), diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index aa9451b413..5c40f282db 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -87,6 +87,9 @@ async function bench(snapshot: ConversationSnapshot) { // Provide-channel contributions land in this bundle the way the runtime // materializes them; the renderer host serves it through provideInfo. const provided: { hooks: Record; props: Record } = { hooks: {}, props: {} } + // Identity-stable currentProvideInfo snapshot (uSES getSnapshot contract), + // materialized on first render after the provide contributions landed. + let infoCell: { sessionId: SessionId; hooks: Record; props: Record } | undefined const sessionsFake = { list, binding: (id: SessionId) => (id === SID @@ -103,9 +106,10 @@ async function bench(snapshot: ConversationSnapshot) { provideInfo: (id: string) => (id === SID ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props } : undefined), - maybeProvideInfo: (id: string | undefined) => (id === SID - ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props } - : { hooks: provided.hooks, props: provided.props }), + currentProvideInfo: { + getSnapshot: () => infoCell ??= { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }, + subscribe: () => () => {}, + }, create: vi.fn(), open: vi.fn(), } diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 87e188bbbd..ec713d4bb1 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -24,6 +24,9 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien const SID = 's1' as SessionId +/** Identity-stable no-session bundle (uSES getSnapshot contract). */ +const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} } + afterEach(cleanup) // The chat store persists under its declared key; clear between cases. beforeEach(() => { @@ -89,30 +92,28 @@ async function bench(nodes: ToolResultNode[]) { subscribe: (fn: () => void) => session.subscribe(fn), }, }) + const provideInfo = (id: string) => { + if (id !== SID) return undefined + if (info === undefined) { + const hooks: Record = { session } + const props: Record = {} + for (const provider of providers) { + const c = provider(bindingOf(SID)) + Object.assign(hooks, c.hooks ?? {}) + Object.assign(props, c.props ?? {}) + } + info = { sessionId: SID, hooks, props } + } + return info + } ctx.provide('sessions', { list, binding: bindingOf, scope: () => actxFake, - provideInfo: (id: string) => { - if (id !== SID) return undefined - if (info === undefined) { - const hooks: Record = { session } - const props: Record = {} - for (const provider of providers) { - const c = provider(bindingOf(SID)) - Object.assign(hooks, c.hooks ?? {}) - Object.assign(props, c.props ?? {}) - } - info = { sessionId: SID, hooks, props } - } - return info - }, - maybeProvideInfo(id: string | undefined) { - // `this` inside an object-literal method is any under strict lint; the - // fake resolves through its own provideInfo above. - /* eslint-disable-next-line @typescript-eslint/no-unsafe-return, - @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ - return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} } + provideInfo, + currentProvideInfo: { + getSnapshot: () => provideInfo(SID), + subscribe: () => () => {}, }, provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} }, scopeOf: () => SID, @@ -254,7 +255,10 @@ describe('registrant load-order seam', () => { binding: () => undefined, scope: () => undefined, provideInfo: () => undefined, - maybeProvideInfo: () => ({ hooks: {}, props: {} }), + currentProvideInfo: { + getSnapshot: () => ABSENT_INFO, + subscribe: () => () => {}, + }, provide: () => () => {}, create: vi.fn(), open: vi.fn(), diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index c50a35110a..4999125c62 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -56,7 +56,11 @@ function bench(over?: BenchOptions) { // Lexicon-only stub: adjudication untouched (undefined slash methods are // never reached — these benches drive plain-draft flows only). ...(lex !== undefined - ? { slash: (() => ({ lexicon: () => lex })) as unknown as NonNullable } + ? { + slash: (() => ({ + lexicon: { getSnapshot: () => lex, subscribe: () => () => {} }, + })) as unknown as NonNullable, + } : {}), }) if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft) @@ -87,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', diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 284ef6c76a..f21f124b78 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/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', diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 414f3c15b4..807650169b 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/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', @@ -234,6 +236,35 @@ describe('scenario H: backspace breaks the token', () => { }) }) +describe('scenario: reference decoration lights up when the lexicon settles', () => { + it('a typed /name token gains the text-ref mark without further input once the roll goes hot', async () => { + let roll: readonly string[] | undefined + let notify: (() => void) | undefined + const b = await scopedBench((slash) => { + slash.registerSource({ + trigger: '/', name: 'skill', + candidates: () => Promise.resolve([]), + onPick: () => undefined, + lexicon: () => roll, + subscribeLexicon: (_session: ClientSessionContext, listener: () => void) => { + notify = listener + return () => { notify = undefined } + }, + } as never) + }) + // Typed before the catalog settled: a plain token, no decoration. + b.type('/deploy now') + expect(b.view.container.querySelector('[data-decoration="text-ref"]')).toBeNull() + // The catalog settles (ui-skill's settle path fires the same notification). + act(() => { + roll = ['deploy'] + notify?.() + }) + const mark = b.view.container.querySelector('[data-decoration="text-ref"]') + expect(mark?.textContent).toBe('/deploy') + }) +}) + describe('scenario I: unknown /xyz + enter', () => { it('adjudication misses in one hop and the whole line rides the default sink', async () => { const b = await bench() diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.ts b/packages/client/ui-conversation/tests/selection-survival.spec.ts index ec50a3f317..533d5ce730 100644 --- a/packages/client/ui-conversation/tests/selection-survival.spec.ts +++ b/packages/client/ui-conversation/tests/selection-survival.spec.ts @@ -11,6 +11,9 @@ import { createChatStore } from '../src/client/stores.ts' const sid = (s: string): SessionId => s as SessionId +/** Identity-stable no-session bundle (uSES getSnapshot contract). */ +const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} } + interface Bench { slots: SlotsService chat: ReturnType @@ -23,7 +26,10 @@ function bench(): Bench { ids: [], byId: {}, current: undefined, phase: 'ready', }), provideInfo: () => undefined, - maybeProvideInfo: () => ({ hooks: {}, props: {} }), + currentProvideInfo: { + getSnapshot: () => ABSENT_INFO, + subscribe: () => () => {}, + }, provide: () => () => {}, }) ctx.provide('workspaces', { diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index b777c85ac3..27c635a1f0 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/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} diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 0d12135311..c3480e1d18 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -10,7 +10,7 @@ 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 + rows: readonly SettingsSectionRow[] renderSlot: SettingsRootComponentProps['renderSlot'] onClose: () => void } @@ -92,20 +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. - // State = ledger version: same-version notifications dedupe to no render. - const [, setSectionsRev] = useState(() => sectionsVersion()) - useEffect( - () => subscribeSections(() => { setSectionsRev(sectionsVersion()) }), - [subscribeSections, sectionsVersion], - ) - const rows = sections() + const rows = useSections(s => s) return ( <> diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index 1a263108bc..c20a041858 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/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 + } } /** * 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 diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 7cb3dfd6d4..f858be9c37 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/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, () => diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts index d7fdfbd546..caec65f3f5 100644 --- a/packages/client/ui-settings/tests/apply.spec.ts +++ b/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() }) diff --git a/packages/client/ui-settings/tests/settings-root.spec.tsx b/packages/client/ui-settings/tests/settings-root.spec.tsx index 9584d500e5..dd340dc2ea 100644 --- a/packages/client/ui-settings/tests/settings-root.spec.tsx +++ b/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() const bump = (next: Row[]) => { act(() => { current = next - version += 1 for (const fn of [...listeners]) fn() }) } diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 677843c844..d34c6ba96d 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -44,6 +44,21 @@ 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() + // Per-session lexicon invalidation listeners (subscribeLexicon consumers). + const lexiconListeners = new Map void>>() + + const notifyLexicon = (sessionId: SessionId): void => { + for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) { + try { + listener() + } catch (error) { + // Contain listener failures: settlement notifies from an ignored + // promise chain (a throw would surface as an unhandled rejection) + // and one faulty consumer must not starve the others. + console.error('[ui-skill] lexicon listener failed:', error) + } + } + } const fetchCatalog = (sessionId: SessionId): Promise => { const existing = fetches.get(sessionId) @@ -58,7 +73,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 +90,7 @@ export function apply(ctx: ClientContext): void { if (entry === undefined) return fetches.delete(key) entry.abort.abort() + notifyLexicon(key) } const clearAll = (): void => { @@ -97,6 +116,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). diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 11f53e142c..0d8f2c57cd 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -208,6 +208,33 @@ describe('lexicon', () => { // Another session's key is independent — cold until its own fetch. expect(source.lexicon!(proj('s2'))).toBeUndefined() }) + + it('subscribeLexicon notifies on catalog settle and on invalidation, per session', async () => { + const { list } = countingList() + const { ctx, source } = await bench(list) + const s1 = vi.fn() + const s2 = vi.fn() + source.subscribeLexicon!(proj('s1'), s1) + source.subscribeLexicon!(proj('s2'), s2) + await source.candidates(proj('s1'), req('')) + expect(s1).toHaveBeenCalledTimes(1) + expect(s2).not.toHaveBeenCalled() + // Reset invalidates every cached session: each key notifies its own listeners. + await source.candidates(proj('s2'), req('')) + ctx.emit('connection/reset') + expect(s1).toHaveBeenCalledTimes(2) + expect(s2).toHaveBeenCalledTimes(2) + }) + + it('an unsubscribed lexicon listener stops receiving notifications', async () => { + const { list } = countingList() + const { source } = await bench(list) + const listener = vi.fn() + const off = source.subscribeLexicon!(proj('s1'), listener) + off() + await source.candidates(proj('s1'), req('')) + expect(listener).not.toHaveBeenCalled() + }) }) describe('pick and codec', () => { diff --git a/packages/client/ui-slash/README.i18n.yaml b/packages/client/ui-slash/README.i18n.yaml index c09d7f4c28..1053e205b0 100644 --- a/packages/client/ui-slash/README.i18n.yaml +++ b/packages/client/ui-slash/README.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 -README.md: d2978695d71686059bfbcbb4fc3ef896d92add4a -README.zh.md: 6aeb078a922aaa93d50ed16b4dbe54329737d018 +# pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md +README.md: 4e363c2682bf91862ec40f3f2174831451fb9b0d +README.zh.md: 76d39673cb853d1889ee84cb9f3595708eae2db3 diff --git a/packages/client/ui-slash/README.md b/packages/client/ui-slash/README.md index d2978695d7..4e363c2682 100644 --- a/packages/client/ui-slash/README.md +++ b/packages/client/ui-slash/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone and the roster is warmed once at scope birth. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. +Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration. diff --git a/packages/client/ui-slash/README.zh.md b/packages/client/ui-slash/README.zh.md index 6aeb078a92..76d39673cb 100644 --- a/packages/client/ui-slash/README.zh.md +++ b/packages/client/ui-slash/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份,roster 在 scope 出生时预热一次。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 +输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` snapshot store 发布。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。 diff --git a/packages/client/ui-slash/src/client/controller.ts b/packages/client/ui-slash/src/client/controller.ts index d3d3567e4d..ab0b26da54 100644 --- a/packages/client/ui-slash/src/client/controller.ts +++ b/packages/client/ui-slash/src/client/controller.ts @@ -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 = createSnapshotStore(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> = + createSnapshotStore>(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 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,25 +284,33 @@ 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 { + /** 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() for (const src of this.deps.roster.all()) { if (src.lexicon === undefined) continue - const names = src.lexicon(projection) + let names: readonly string[] | undefined + try { + names = src.lexicon(projection) + } catch (error) { + // A faulty source drops silently with a console record (the + // candidate-fetch failure policy); the refresh runs inside + // notification callbacks, where a throw would starve other consumers. + console.error(`[ui-slash] source "${src.name}" lexicon failed:`, error) + continue + } if (names === undefined) continue 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. */ diff --git a/packages/client/ui-slash/src/client/service.ts b/packages/client/ui-slash/src/client/service.ts index 094f325393..c47d44c3d4 100644 --- a/packages/client/ui-slash/src/client/service.ts +++ b/packages/client/ui-slash/src/client/service.ts @@ -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,16 @@ 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()) { + try { + controller.sourceAdded(src) + } catch (error) { + // Contain faulty source callbacks (warm/subscribeLexicon): the + // registration must stand with a usable disposer and the remaining + // controllers must still be notified. + console.error(`[ui-slash] source "${src.trigger}${src.name}" late-registration setup failed:`, error) + } + } return () => { const at = live.sources.indexOf(src) if (at < 0) return diff --git a/packages/client/ui-slash/src/types.ts b/packages/client/ui-slash/src/types.ts index 2fb26fa4b6..4b9bd64408 100644 --- a/packages/client/ui-slash/src/types.ts +++ b/packages/client/ui-slash/src/types.ts @@ -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 } diff --git a/packages/client/ui-slash/tests/service.spec.ts b/packages/client/ui-slash/tests/service.spec.ts index 379d7bbe8a..099e2bb4f5 100644 --- a/packages/client/ui-slash/tests/service.spec.ts +++ b/packages/client/ui-slash/tests/service.spec.ts @@ -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', () => { diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 1f30f7027b..7b980571ef 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/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 = { */ export type SlotComponent

= (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` 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> + +/** + * Selector-hook share synthesized from a hooks compartment: each source + * `name` becomes a `use` selector hook over its snapshot type. + */ +export type PropsHooks = { + [N in keyof HS & string as `use${Capitalize}`]: + SnapshotSelectorHook ? T : never> +} + +/** + * The component-side view of an inject face: the reserved `hooks` + * compartment (when declared) arrives as bound `use` selector hooks; + * every other member passes through verbatim. + */ +export type InjectFace = + I extends { hooks: infer HS extends HooksSources } ? Omit & PropsHooks : 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 & PropsRenderSlots & PropsStore & I & MatchedShare +> = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare /** * Inject factory parameter list, derived from the registration's declaration: diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index 5b7de0d6f1..4a437887d1 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -105,18 +105,14 @@ export interface SlotRendererHost { sessions: { /** Session list source backing the useSessions standard hook. */ list: HostObservable - /** Current-session source used by SessionProvider. */ - current: HostObservable - /** Resolve a definite session bundle, or undefined when the id is unknown. */ - provideInfo(id: string): SessionProvideInfo | undefined /** - * Resolve the current-session-optional standard props bundle. The result - * always carries the static provider roster, even when `id` is absent or - * cannot resolve to a live session. - * @param id - current session id, when selected. - * @returns the optional provide info. + * Atomic current-session provide projection used by SessionProvider: + * selection changes and provider-roster changes publish through this one + * source, so a stable current id cannot strand mounted entries on an + * obsolete hook/prop schema. Carries the static roster with sessionId + * undefined while no current session resolves. */ - maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo + provideInfo: HostObservable } /** Workspace-side standard-kit sources. */ workspaces: { diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 3ad1543c68..4170f8e339 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -39,6 +39,10 @@ export function apply(ctx: ClientContext): void { // The list snapshot is always warm — the full running-children roster. return childLabels(session, '') }, + subscribeLexicon(_session, listener) { + // The roll derives from the list snapshot, so its change feed IS the list's. + return sessions.list.subscribe(listener) + }, onPick({ candidate }) { // Decision 21: plain-text reference — the literal lands in the draft // and ships to the model verbatim (trailing space closes the token). diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index fc74470406..d138295276 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -32,17 +32,31 @@ function sessionsWith(sessions: SessionSummary[]) { const byId: Record = {} for (const s of sessions) byId[s.id] = s const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState - return { list: { getSnapshot: () => snapshot } } + const subs = new Set<() => void>() + return { + list: { + getSnapshot: () => snapshot, + subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } }, + }, + notify: () => { for (const fn of [...subs]) fn() }, + listenerCount: () => subs.size, + } } -/** Boot the plugin over fake slash/sessions faces; returns the captured source. */ -async function bench(sessions: SessionSummary[]): Promise { +/** Boot the plugin over fake slash/sessions faces; returns the captured source and the list face. */ +async function fullBench(sessions: SessionSummary[]) { const ctx = new Context() let captured: SlashSource | undefined + const face = sessionsWith(sessions) ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) - ctx.provide('sessions', sessionsWith(sessions)) + ctx.provide('sessions', face) await ctx.plugin({ inject: [...inject], apply }).await() - return captured! + return { source: captured!, face } +} + +/** Source-only bench for the behavior-contract suites. */ +async function bench(sessions: SessionSummary[]): Promise { + return (await fullBench(sessions)).source } const FAMILY: SessionSummary[] = [ @@ -113,6 +127,19 @@ describe('lexicon', () => { expect(source.lexicon!(proj('parent'))).toEqual(['worker-1', 'worker-2', 'scout']) expect(source.lexicon!(proj('childless'))).toEqual([]) }) + + it('subscribeLexicon forwards the session-list change feed and unsubscribes cleanly', async () => { + const { source, face } = await fullBench(FAMILY) + let notified = 0 + const off = source.subscribeLexicon!(proj('parent'), () => { notified += 1 }) + expect(face.listenerCount()).toBe(1) + face.notify() + expect(notified).toBe(1) + off() + expect(face.listenerCount()).toBe(0) + face.notify() + expect(notified).toBe(1) + }) }) describe('pick and codec', () => { diff --git a/packages/client/web-react/src/scoped-slots.tsx b/packages/client/web-react/src/scoped-slots.tsx index 3ef01d7390..5cea31cd6c 100644 --- a/packages/client/web-react/src/scoped-slots.tsx +++ b/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` 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>)) { + const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` + bound[hookName] = observableHook(source) + } + return bound } function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps { diff --git a/packages/client/web-react/src/session-provider.tsx b/packages/client/web-react/src/session-provider.tsx index 79cb763a3e..a9679e460c 100644 --- a/packages/client/web-react/src/session-provider.tsx +++ b/packages/client/web-react/src/session-provider.tsx @@ -90,9 +90,9 @@ function useAbsentSnapshot(_selector: (snapshot: never) => S, _equal?: (a: S, */ export function SessionMaybeProvider({ children }: { children: ReactNode }) { const host = useHost() - const id = observableHook(host.sessions.current)(s => s) + const info = observableHook(host.sessions.provideInfo)(s => s) return ( - + {children} ) @@ -107,17 +107,17 @@ export interface SessionProviderProps { } /** - * Framework-wired session area: subscribes to the host's current-session - * source, resolves the session cell, and remounts the body under - * `key={sessionId}` so a session switch rebuilds the session subtree. This - * dependency-inverted layer uses plain string ids; `PropsRuntime` applies the - * branded type at the component boundary. + * Framework-wired session area: subscribes to the host's current provide + * source and remounts the body under `key={sessionId}` so a session switch + * rebuilds the session subtree. This dependency-inverted layer uses plain + * string ids; `PropsRuntime` applies the branded type at the component + * boundary. */ export function SessionProvider({ empty, children }: SessionProviderProps) { const host = useHost() - const id = observableHook(host.sessions.current)(s => s) - const info = id === undefined ? undefined : host.sessions.provideInfo(id) - if (id === undefined || info === undefined) return <>{empty?.() ?? null} + const info = observableHook(host.sessions.provideInfo)(s => s) + const id = info.sessionId + if (id === undefined) return <>{empty?.() ?? null} return ( {children(id)} diff --git a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx b/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx index 381170527a..6649018b0b 100644 --- a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx @@ -26,6 +26,7 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'> /** Passthrough host over the real core (store/session seats unused here). */ function hostOver(core: SlotCore): SlotRendererHost { + const absentInfo = { sessionId: undefined, hooks: {}, props: {} } return { subscribe: (key, fn) => core.subscribe(key, fn), getVersion: key => core.getVersion(key), @@ -35,9 +36,7 @@ function hostOver(core: SlotCore): SlotRendererHost { storeOf: () => undefined, sessions: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, - current: { getSnapshot: () => undefined, subscribe: () => () => {} }, - provideInfo: () => undefined, - maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }), + provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, }, workspaces: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx index 51b12d2385..971a6ad060 100644 --- a/packages/client/web-react/tests/scoped-slots.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots.spec.tsx @@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest' import { act, fireEvent, render } from '@testing-library/react' import { useEffect, type ReactNode } from 'react' import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' import { createSlotRenderer, SessionProvider, SlotOwnershipError, StaleAuthorizationError, type RenderOpts, type SessionProvideInfo, @@ -85,7 +86,9 @@ function makeHost() { const storeCache = new Map>() const list = observable<{ ids: string[] }>({ ids: [] }) const workspaces = observable<{ ids: string[] }>({ ids: [] }) - const current = observable(undefined) + const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: {}, props: {} } + const provide = observable(absentInfo) + let currentId: string | undefined const infos = new Map() const bump = (key: string) => { @@ -123,10 +126,7 @@ function makeHost() { }, sessions: { list, - current, - provideInfo: id => infos.get(id), - maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id)) - ?? { sessionId: undefined, hooks: {}, props: {} }, + provideInfo: provide, }, workspaces: { list: workspaces }, } @@ -134,7 +134,14 @@ function makeHost() { host, list, workspaces, - current, + // Same driver surface as the old current cell: set(id) publishes the + // resolved bundle (or the absent projection) through the provide source. + current: { + set: (id: string | undefined) => { + currentId = id + provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo) + }, + }, declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) }, add: (key: string, partial: Omit & { options?: StoredEntry['options'] }) => { const entry = entryOf(partial) @@ -161,6 +168,7 @@ function makeHost() { props: {}, } infos.set(id, info) + if (currentId === id) provide.set(info) return info }, } @@ -740,6 +748,25 @@ describe('inject: execution point, parameter derivation, cache granularity', () expect(inject).toHaveBeenCalledWith() }) + it('binds the inject hooks compartment into use selector hooks (sources never reach the component)', () => { + const h = makeHost() + h.declare('k.single', SINGLE_ROOT) + const badge = observable('cold') + const seen: Record[] = [] + h.add('k.single', { + component: (props: { useBadge?: (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) diff --git a/packages/client/web-react/tests/session-provider.spec.tsx b/packages/client/web-react/tests/session-provider.spec.tsx index 2e055bcee2..c1f1f648a2 100644 --- a/packages/client/web-react/tests/session-provider.spec.tsx +++ b/packages/client/web-react/tests/session-provider.spec.tsx @@ -9,7 +9,7 @@ import { useEffect, useRef } from 'react' import { describe, expect, it, vi } from 'vitest' import { act, render } from '@testing-library/react' -import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' import { createSlotRenderer, SessionProvider, type SessionProvideInfo, type SlotRendererHost, @@ -26,12 +26,14 @@ function observable(initial: T) { } /** - * Minimal host: SessionProvider only reads sessions.current/cell, but it must + * Minimal host: SessionProvider only reads sessions.provideInfo, but it must * render inside the renderer tree (HostContext), so the harness mounts a real * root entry whose body is the test's render-prop provider. */ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.ReactNode) => React.ReactNode }) { - const current = observable(undefined) + const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} } + const provide = observable(absentInfo) + let currentId: string | undefined const infos = new Map() const sessionEntries: StoredEntry[] = [] const rootEntry: StoredEntry = { @@ -49,16 +51,20 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea storeOf: () => undefined, sessions: { list: observable({ ids: [] }), - current, - provideInfo: id => infos.get(id), - maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id)) - ?? { sessionId: undefined, hooks: { session: undefined }, props: {} }, + provideInfo: provide, }, workspaces: { list: observable({ items: [] }) }, } return { host, - current, + // Same driver surface as the old current cell: set(id) publishes the + // resolved bundle (or the absent projection) through the provide source. + current: { + set: (id: string | undefined) => { + currentId = id + provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo) + }, + }, addSession: (id: string) => { // Bare source per bundle (identity-stable): the machinery binds useSession from it. const info: SessionProvideInfo = { @@ -67,8 +73,14 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea props: {}, } infos.set(id, info) + if (currentId === id) provide.set(info) return info }, + /** Swap one session's bundle in place (roster-change stand-in); republish when current. */ + replaceSession: (info: SessionProvideInfo) => { + infos.set(info.sessionId, info) + if (currentId === info.sessionId) provide.set(info) + }, registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) }, } } @@ -149,6 +161,28 @@ describe('SessionProvider', () => { expect(seen.at(-1)!['sessionId']).toBe('s2') }) + it('republishes a mounted session entry when its provide bundle changes under the same id', () => { + const seen: unknown[] = [] + const h = makeHost({ + root: renderSlot => {() => renderSlot('k.session', {})}, + }) + const original = h.addSession('s1') + h.registerSession({ + component: (props: { feature?: string }) => { + seen.push(props.feature) + return null + }, + options: {}, + }) + render(<>{createSlotRenderer().renderRoot(h.host, {})}) + act(() => { h.current.set('s1') }) + expect(seen.at(-1)).toBeUndefined() + // A provider-roster change rematerializes the bundle; the provide source + // must carry it to already-mounted entries without a selection change. + act(() => { h.replaceSession({ ...original, props: { feature: 'now-live' } }) }) + expect(seen.at(-1)).toBe('now-live') + }) + it('fails loud when mounted outside the renderer tree (no host channel)', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) expect(() => render( diff --git a/packages/client/web-react/tests/stale-authorization.spec.tsx b/packages/client/web-react/tests/stale-authorization.spec.tsx index 6c3e5d194b..df3bc51d2b 100644 --- a/packages/client/web-react/tests/stale-authorization.spec.tsx +++ b/packages/client/web-react/tests/stale-authorization.spec.tsx @@ -21,6 +21,7 @@ function makeHost() { const versions = new Map() const subs = new Map void>>() const live = new Set() + const absentInfo = { sessionId: undefined, hooks: {}, props: {} } const bump = (key: string) => { versions.set(key, (versions.get(key) ?? 0) + 1) for (const fn of [...(subs.get(key) ?? [])]) fn() @@ -39,9 +40,7 @@ function makeHost() { storeOf: () => undefined, sessions: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, - current: { getSnapshot: () => undefined, subscribe: () => () => {} }, - provideInfo: () => undefined, - maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }), + provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, }, workspaces: { list: { getSnapshot: () => ({}), subscribe: () => () => {} },