Kaynağa Gözat

refactor(gui): move the snapshot-store engine into the client runtime

The data layer no longer depends on the React glue package, and business
plugins no longer depend on web-react at all:

- The store engine (zustand vanilla + immer + persist + dev freeze),
  defineStore, and shallowEqual move to @deepseek-ai/dsh-client-runtime,
  exported from the ./client main entry — no ./store subpath survives on
  either package (the web-react one is deleted, none is opened on runtime).
- Store products are bare snapshot sources: useSelector leaves
  SnapshotStore/StoreInstance and Session; every hook is composed at the
  binding site in web-react's renderer (per-source cached uSES binding).
  The SlotRendererHost sessions face carries bare observables only.
- SessionProvider becomes a standard-kit seat: an entry whose children
  declare a session-scope slot receives the framework component as a prop,
  retiring the last value import of web-react from plugin packages.
  UseSession and the session-area types now live in ui-slots.
- web-react shrinks to the shell-only React glue (renderer, providers,
  uSES bridge); zustand/immer belong to runtime alone; the module-table
  seed and tsdown externals drop the web-react/store seat.
- NODE_ENV replacement is defined once in the shared tsdown client preset
  (browser bundles inline the engine and lost vite's define); the 3-line
  process.env typecheck shim moves to runtime with the engine.
- Stray tsc artifacts (.js/.d.ts/.d.ts.map beside sources under src/)
  swept repo-wide; they shadow real sources under vitest resolution.

Verified: both aggregate typecheck programs at zero; 604 client tests
green; repo-wide grep for web-react/store at zero; real-host playwright
run 7/7 including persist round-trip.

ci: fix test/docs
imccyu 2 ay önce
ebeveyn
işleme
8b3d1ac943
77 değiştirilmiş dosya ile 484 ekleme ve 317 silme
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml
  5. 2 2
      .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
  6. 2 2
      .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md
  7. 10 3
      knip.json
  8. 2 2
      packages/client/AGENTS.md
  9. 1 1
      packages/client/i18n/package.json
  10. 7 5
      packages/client/i18n/src/client/index.ts
  11. 1 1
      packages/client/i18n/tsconfig.json
  12. 3 2
      packages/client/runtime/package.json
  13. 9 2
      packages/client/runtime/src/client/index.ts
  14. 1 1
      packages/client/runtime/src/client/loader/index.ts
  15. 6 6
      packages/client/runtime/src/client/sessions/service.ts
  16. 7 6
      packages/client/runtime/src/client/sessions/session.ts
  17. 11 17
      packages/client/runtime/src/client/store/index.ts
  18. 0 0
      packages/client/runtime/src/env.d.ts
  19. 4 2
      packages/client/runtime/tests/sessions-service.spec.ts
  20. 10 4
      packages/client/runtime/tests/slots-service.spec.ts
  21. 1 1
      packages/client/runtime/tests/store.spec.ts
  22. 15 1
      packages/client/tsdown.client.ts
  23. 0 1
      packages/client/ui-conversation/package.json
  24. 2 2
      packages/client/ui-conversation/src/client/chat/ChatView.tsx
  25. 1 1
      packages/client/ui-conversation/src/client/chat/StatsLine.tsx
  26. 1 1
      packages/client/ui-conversation/src/client/contract/toolview.ts
  27. 1 1
      packages/client/ui-conversation/src/client/contract/views.ts
  28. 1 1
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
  29. 1 1
      packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
  30. 1 1
      packages/client/ui-conversation/src/client/stores.ts
  31. 2 2
      packages/client/ui-conversation/tests/apply-inject.spec.tsx
  32. 1 1
      packages/client/ui-conversation/tests/chat-apply.spec.tsx
  33. 3 3
      packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx
  34. 3 3
      packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
  35. 1 1
      packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
  36. 4 4
      packages/client/ui-conversation/tests/chat-view.spec.tsx
  37. 1 1
      packages/client/ui-conversation/tests/coverage-tails.spec.tsx
  38. 8 22
      packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
  39. 26 0
      packages/client/ui-conversation/tests/hook.ts
  40. 2 2
      packages/client/ui-conversation/tests/selection-survival.spec.ts
  41. 12 12
      packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
  42. 7 7
      packages/client/ui-conversation/tests/skeleton.spec.tsx
  43. 0 1
      packages/client/ui-layout/package.json
  44. 8 7
      packages/client/ui-layout/src/client/AppFrame.tsx
  45. 1 1
      packages/client/ui-layout/src/client/stores.ts
  46. 23 19
      packages/client/ui-layout/tests/app-frame.spec.tsx
  47. 0 1
      packages/client/ui-sidebar/package.json
  48. 1 1
      packages/client/ui-sidebar/tests/apply.spec.tsx
  49. 11 5
      packages/client/ui-sidebar/tests/sidebar-root.spec.tsx
  50. 1 1
      packages/client/ui-slots/README.md
  51. 33 2
      packages/client/ui-slots/src/index.ts
  52. 19 5
      packages/client/ui-slots/src/renderer.ts
  53. 10 10
      packages/client/ui-slots/src/store.ts
  54. 6 0
      packages/client/ui-slots/tests/type-chain.spec.tsx
  55. 0 1
      packages/client/ui-trajectory/package.json
  56. 1 1
      packages/client/ui-trajectory/src/client/TrajectoryStatsHeader.tsx
  57. 1 1
      packages/client/ui-trajectory/src/client/TrajectoryView.tsx
  58. 1 1
      packages/client/ui-trajectory/src/client/WaterfallView.tsx
  59. 6 6
      packages/client/ui-trajectory/tests/views.spec.tsx
  60. 1 1
      packages/client/web-react/README.md
  61. 2 9
      packages/client/web-react/package.json
  62. 8 6
      packages/client/web-react/src/bind.ts
  63. 9 16
      packages/client/web-react/src/index.ts
  64. 17 7
      packages/client/web-react/src/scoped-slots.tsx
  65. 3 2
      packages/client/web-react/src/session-provider.tsx
  66. 10 3
      packages/client/web-react/tests/bind.spec.tsx
  67. 90 16
      packages/client/web-react/tests/scoped-slots.spec.tsx
  68. 18 6
      packages/client/web-react/tests/session-provider.spec.tsx
  69. 6 16
      packages/client/web-react/tsdown.config.ts
  70. 0 1
      packages/client/web/package.json
  71. 1 1
      packages/client/web/src/AppRoot.tsx
  72. 1 1
      packages/client/web/src/boot.tsx
  73. 0 2
      packages/client/web/src/seed.ts
  74. 3 4
      packages/client/web/tests/app-root.spec.tsx
  75. 8 5
      packages/client/web/tests/boot.spec.tsx
  76. 8 26
      pnpm-lock.yaml
  77. 1 1
      tsconfig.base.json

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.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
-2026-07-19-gui-web-client-architecture.md: 32ec0a371f78aff8841949b983ddcca475ed6831
-2026-07-19-gui-web-client-architecture.zh.md: 8a929a3e253d26b7394ad374dc0844b03fcd8b5c
+2026-07-19-gui-web-client-architecture.md: f704a00c3b38f5a3a62d66cb37b121f0f21770df
+2026-07-19-gui-web-client-architecture.zh.md: 3aa8fdbe82102d8c920a9f77635320c432d8b95d

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md

@@ -86,7 +86,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──
 
 The glue package is the whole ctx↔React boundary; components stay framework-free.
 
-- The snapshot store engine: zustand vanilla with draft-based updates, `flush: 'sync'` by default (controlled inputs need same-tick echo) with opt-in `'raf'` batching for frame-driven stores, opt-in whole-value localStorage persistence, dev-mode deep freeze. Plugins reach it only through `defineStore` declarations per the [slot system standard](2026-07-22-slot-type-chain-implementation.md); the framework (runtime data layer, renderer machinery) is the engine's only direct consumer. Both a Session object and a snapshot store satisfy the one data contract React consumes: `ObservableSnapshot<T>` (`getSnapshot`/`subscribe`).
+- The snapshot store engine **lives in the runtime package** (zustand vanilla with draft-based updates, `flush: 'sync'` by default with opt-in `'raf'` batching, opt-in whole-value localStorage persistence, dev-mode deep freeze — all exported from `runtime`'s `./client` main entry, no subpath): store products are bare observable sources with no hook members. Plugins reach the engine only through `defineStore` declarations per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). web-react composes every hook at the binding site (`bindSnapshotSelector`, per-source cached) from the one data contract React consumes: `ObservableSnapshot<T>` (`getSnapshot`/`subscribe`) — a Session object and a snapshot store both satisfy it. Business plugin packages depend on runtime and ui-slots only; web-react is shell-only glue.
 - `bindSnapshotSelector(source)`: binds a source into a typed selector hook over uSES-with-selector. The four uSES contract clauses hold by construction: getSnapshot returns the cached reference; subscribe is a bind-time closure (reference-stable forever); pure CSR passes no server snapshot; equality defaults to `Object.is` with `shallowEqual` opt-in per call.
 - `useInvoke(fn)`: wraps an async action into a stable trigger plus pending flag; pending rides a per-hook external store read through uSES (no setState on the render path), concurrent invocations are counted, and the invoke reference never changes.
 - Equality protocol, whole chain: producers use structural sharing; consumers short-circuit with `Object.is` or `shallowEqual`; `React.memo` shallow. Deep comparison is banned everywhere.

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md

@@ -86,7 +86,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──
 
 胶水包就是整条 ctx↔React 边界;组件保持零框架依赖。
 
-- 快照 store 引擎:zustand vanilla + 草稿式更新,缺省 `flush: 'sync'`(受控输入要求同 tick 回响),帧驱动 store 可选 `'raf'` 合批,可选整值 localStorage 持久化,dev 深冻结。插件只经 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 的 `defineStore` 声明触及它;引擎的直接消费方只有框架(runtime 数据层、渲染器机械)。Session 对象与快照 store 同构满足 React 消费的唯一数据契约:`ObservableSnapshot<T>`(`getSnapshot`/`subscribe`)。
+- 快照 store 引擎**住 runtime 包**(zustand vanilla + 草稿式更新,缺省 `flush: 'sync'`,帧驱动 store 可选 `'raf'` 合批,可选整值 localStorage 持久化,dev 深冻结——全部从 `runtime` 的 `./client` 主出口导出,无子路径):store 产物是裸的可观察源,不带任何 hook 成员。插件只经 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 的 `defineStore` 声明触及引擎。web-react 在绑定处(`bindSnapshotSelector`,按源缓存)从 React 消费的唯一数据契约合成每个 hook:`ObservableSnapshot<T>`(`getSnapshot`/`subscribe`)——Session 对象与快照 store 同构满足它。业务插件包只依赖 runtime 与 ui-slots;web-react 是仅壳可用的胶水。
 - `bindSnapshotSelector(source)`:把一个源绑定为经 uSES-with-selector 的带类型 selector hook。uSES 契约四条按构造成立:getSnapshot 恒返缓存引用;subscribe 是绑定期闭包(引用永稳);纯 CSR 不传 server snapshot;相等性缺省 `Object.is`,按调用可选 `shallowEqual`。
 - `useInvoke(fn)`:把异步动作包成引用恒定的触发器加 pending 标志;pending 走 per-hook 外部 store 经 uSES 读出(渲染路径零 setState),并发调用计数,invoke 引用永不变。
 - 相等性协议,全链一致:生产端结构共享;消费端以 `Object.is` 或 `shallowEqual` 短路;`React.memo` 浅比较。深比较全链禁止。

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.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
-2026-07-22-slot-type-chain-implementation.md: f6c5e09d53e442c4f10f30ad03d7d50729ebba0f
-2026-07-22-slot-type-chain-implementation.zh.md: 61ad6e86042bb0899604461b976ab55da037a765
+2026-07-22-slot-type-chain-implementation.md: 1e9bd711e8316e2556fe238eb0a20d76e1d0d5b1
+2026-07-22-slot-type-chain-implementation.zh.md: 0eab839d033faac2f2c3356900c7ca1cd69d2dc9

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

@@ -51,7 +51,7 @@ Parity rule: **the declaring entry holds the exclusive right to render its child
 
 ### The store seat: framework engine, registrant schema
 
-The framework owns exactly one subscription machine (the uSES snapshot store engine — zustand vanilla + immer + optional localStorage persistence). What a store *contains* is the registrant's declaration, written as a factory so no module-level handle exists (a module-scoped handle would be a de-facto singleton surviving plugin reloads):
+The framework owns exactly one subscription machine: the snapshot store engine (zustand vanilla + immer + optional localStorage persistence) lives in the **runtime package** (`./client` main entry — no subpath), producing bare observable sources; web-react binds them into hooks at the outlet (per-source cached uSES binding). What a store *contains* is the registrant's declaration, written as a factory so no module-level handle exists (a module-scoped handle would be a de-facto singleton surviving plugin reloads):
 
 ```ts ignore-check
 export function createChatStore() {
@@ -80,7 +80,7 @@ Hooks are framework-made only: `useSession`, `useSessions`, `useStore`, `renderS
 
 ### Tree context and the renderer seam
 
-`SessionProvider` is a framework component, self-wired (it reads the runtime's current-session state internally; the assembler passes nothing), render-prop shaped — `children(sessionId)` with an `empty` branch, remounting under `key={sessionId}`. `BindingContext` is machinery-internal; business components see zero React contexts. Inject factories execute inside the outlet on purpose (per-entry error boundaries catch them; a crashing registrant blacks out only its own entry while assembly errors rethrow); the outlet reads tree context as a machinery-only implicit parameter — the "identity from the register closure, situation from the tree position" split.
+`SessionProvider` is a framework component **delivered as a standard-kit seat**: an entry whose `children` declare a session-scope slot receives it as a prop (type in ui-slots, value injected by the renderer) — components never value-import it. It is self-wired (it reads the runtime's current-session state internally; the assembler passes nothing), render-prop shaped — `children(sessionId)` with an `empty` branch, remounting under `key={sessionId}`. `BindingContext` is machinery-internal; business components see zero React contexts. Inject factories execute inside the outlet on purpose (per-entry error boundaries catch them; a crashing registrant blacks out only its own entry while assembly errors rethrow); the outlet reads tree context as a machinery-only implicit parameter — the "identity from the register closure, situation from the tree position" split.
 
 Rendering lives behind an install seam so the runtime stays React-free: `SlotRenderer` (interface in ui-slots, implementation `createSlotRenderer()` in web-react) is installed once at shell boot via `ctx.slots.install(...)`; double install and render-before-install throw. Ownership bookkeeping is a single `Map<key, entry>` in the service — ledger, slots, contributions, render bindings, and store instances all live and die on the one entry axis, which closes the stale-authority window across plugin reloads by construction (a disposed entry's captured `renderSlot` throws a stale-authorization error on entry).
 

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

@@ -51,7 +51,7 @@ ctx.slots.register({
 
 ### store 席位:引擎归框架,schema 归注册方
 
-框架拥有恰好一台订阅机械(uSES 快照 store 引擎——zustand vanilla + immer + 可选 localStorage 持久化)。store 里*装什么*是注册方的声明,且必须写成工厂函数,使模块级句柄根本无从存在(模块级句柄会成为跨插件重载存活的事实单例):
+框架拥有恰好一台订阅机械:快照 store 引擎(zustand vanilla + immer + 可选 localStorage 持久化)住 **runtime 包**(`./client` 主出口——无子路径),产出裸的可观察源;web-react 在 outlet 处把它们绑定成 hook(按源缓存的 uSES 绑定)。store 里*装什么*是注册方的声明,且必须写成工厂函数,使模块级句柄根本无从存在(模块级句柄会成为跨插件重载存活的事实单例):
 
 ```ts ignore-check
 export function createChatStore() {
@@ -80,7 +80,7 @@ hook 只许框架造:`useSession`、`useSessions`、`useStore`、`renderSlot`
 
 ### 树上语境与渲染器安装缝
 
-`SessionProvider` 是框架组件,框架自接线(内部自读 runtime 的当前会话状态,装配方零传参),render-prop 形——`children(sessionId)` 外加 `empty` 分支,以 `key={sessionId}` 重挂。`BindingContext` 属机械内部;业务组件可见的 React Context 为零。inject 工厂有意在 outlet 内部执行(per-entry 错误边界接得住它们;崩溃的注册方只黑掉自己那一格,装配错误则重抛);outlet 把树上语境当作仅机械可用的暗参读取——即「身份出自 register 闭包、现场出自树位置」的分工。
+`SessionProvider` 是框架组件,**以标配席形式送达**:`children` 里声明了 session scope 坑的 entry 经 prop 收到它(类型住 ui-slots,值由渲染器注入)——组件永不对它做值 import。它框架自接线(内部自读 runtime 的当前会话状态,装配方零传参),render-prop 形——`children(sessionId)` 外加 `empty` 分支,以 `key={sessionId}` 重挂。`BindingContext` 属机械内部;业务组件可见的 React Context 为零。inject 工厂有意在 outlet 内部执行(per-entry 错误边界接得住它们;崩溃的注册方只黑掉自己那一格,装配错误则重抛);outlet 把树上语境当作仅机械可用的暗参读取——即「身份出自 register 闭包、现场出自树位置」的分工。
 
 渲染住在一条安装缝之后,runtime 因此保持 React-free:`SlotRenderer`(接口住 ui-slots,实现 `createSlotRenderer()` 住 web-react)在壳 boot 时经 `ctx.slots.install(...)` 安装一次;双重安装与安装前渲染均 throw。归属记账是服务里的单一 `Map<key, entry>`——账本、坑、贡献、渲染绑定、store 实例全部沿同一条 entry 轴生灭,跨插件重载的陈旧权威窗口由此在构造上关闭(已 dispose 的 entry 所捕获的 `renderSlot`,一进入口即抛陈旧授权(stale-authorization)错误)。
 

+ 10 - 3
knip.json

@@ -106,6 +106,16 @@
         "tests/**/*.tsx"
       ]
     },
+    "packages/client/web-react": {
+      "entry": [
+        "tests/**/*.spec.tsx"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "src/**/*.tsx",
+        "tests/**/*.tsx"
+      ]
+    },
     "packages/client/ui-layout": {
       "entry": [
         "tests/**/*.spec.ts",
@@ -116,9 +126,6 @@
         "src/**/*.tsx",
         "tests/**/*.ts",
         "tests/**/*.tsx"
-      ],
-      "ignoreDependencies": [
-        "@deepseek-ai/dsh-client-ui-slots"
       ]
     },
     "website": {

+ 2 - 2
packages/client/AGENTS.md

@@ -32,8 +32,8 @@ The `/client` surface of a UI plugin package is a contract face, not a convenien
 
 The stack has one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md):
 
-1. **Data object layer** (`runtime`'s sessions machinery, React-free): `ConnectionController` → `SessionManager` → `Session` own all business state (event windows, streaming accumulation, reconnect machine). Zero React imports — grep-assertable.
-2. **Render machinery** (`web-react`): the whole ctx↔React boundary — slot renderer/outlets, `SessionProvider`, the uSES bridge, the store engine. The only code that reads React contexts or runs subscriptions.
+1. **Data object layer** (`runtime`, React-free): `ConnectionController` → `SessionManager` → `Session` own all business state (event windows, streaming accumulation, reconnect machine), and the snapshot-store engine (zustand/immer, `defineStore`, `shallowEqual`) lives here too — store products are bare observable sources with no hook members. Zero React imports — grep-assertable.
+2. **Render machinery** (`web-react`, shell-only glue): the whole ctx↔React boundary — slot renderer/outlets, `SessionProvider`, the uSES bridge. Every hook is composed here at the binding site from bare sources; business plugin packages carry no web-react dependency at all.
 3. **Presentation components** (plugin packages' `src/client/`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; everything arrives through the four props shares.
 
 Non-negotiables across the layers:

+ 1 - 1
packages/client/i18n/package.json

@@ -33,7 +33,7 @@
   },
   "license": "BSD-3-Clause",
   "dependencies": {
-    "@deepseek-ai/dsh-client-web-react": "workspace:^"
+    "@deepseek-ai/dsh-client-runtime": "workspace:^"
   },
   "peerDependencies": {
     "@deepseek-ai/dsh-invariants": "^0.0.1",

+ 7 - 5
packages/client/i18n/src/client/index.ts

@@ -5,11 +5,13 @@
  * Contract: api-contracts v3 section 8.
  */
 import type { Context } from 'cordis'
-// Engine subpath: createSnapshotStore left the public face in the slot
-// terminal rework (business stores go through defineStore); framework data
-// stores like this locale cell keep the engine via './store'.
-import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
+// The snapshot-store engine lives in runtime (store relocation): framework
+// data stores like this locale cell use it directly. The store carries no
+// hook — a React consumer binds a selector hook via web-react's
+// bindSnapshotSelector at its own seam (none exists today; the current
+// consumers are translate() reads and test-side subscribe/set).
+import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import { en } from '../locales/en.ts'
 import { zh } from '../locales/zh.ts'
 

+ 1 - 1
packages/client/i18n/tsconfig.json

@@ -18,7 +18,7 @@
       "path": "../../../vendor/cordis"
     },
     {
-      "path": "../web-react"
+      "path": "../runtime"
     },
     {
       "path": "../../support/invariants"

+ 3 - 2
packages/client/runtime/package.json

@@ -37,10 +37,11 @@
   "dependencies": {
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
-    "@deepseek-ai/dsh-client-web-react": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "immer": "^10.1.1",
     "react": "^18.2.0",
-    "@deepseek-ai/dsh-session": "workspace:^"
+    "zustand": "~4.4.7"
   },
   "peerDependencies": {
     "@deepseek-ai/dsh-invariants": "^0.0.1",

+ 9 - 2
packages/client/runtime/src/client/index.ts

@@ -12,7 +12,7 @@
 import type { Context } from 'cordis'
 import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
-import type { SnapshotStore, UseSession } from '@deepseek-ai/dsh-client-web-react'
+import type { SnapshotStore } from './store/index.ts'
 import { SlotsService } from './slots.ts'
 import { SessionsService } from './sessions/service.ts'
 import type { SessionListState } from './sessions/service.ts'
@@ -25,6 +25,13 @@ export type { RootOwnerProps } from './slots.ts'
 export { SessionsService, scopeOf } from './sessions/service.ts'
 export type { Session } from './sessions/session.ts'
 export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
+// The snapshot-store engine lives here since the store migration (the data
+// layer owns its substrate; web-react is React glue only). The './client'
+// main export is the single serving door — no store subpath.
+export { createSnapshotStore, defineStore, shallowEqual } from './store/index.ts'
+export type {
+  EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
+} from './store/index.ts'
 export type {
   AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
   PendingInteraction, RunningToolCall, SteeringMessageNode,
@@ -45,7 +52,7 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
 export type ClientContext = Context
 
 /** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */
-export type UseConversationSession = UseSession<ConversationSnapshot>
+export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
 
 /**
  * One tool call as the chat flow renders it: still-running (spinner card) or

+ 1 - 1
packages/client/runtime/src/client/loader/index.ts

@@ -17,7 +17,7 @@
  * load one by one in inject topology.
  */
 import type { Context } from 'cordis'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
+import { createSnapshotStore } from '../store/index.ts'
 import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
 
 export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'

+ 6 - 6
packages/client/runtime/src/client/sessions/service.ts

@@ -15,11 +15,9 @@
  */
 import type { Context, Fiber } from 'cordis'
 import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
-// Engine reach-through: the store subpath is the framework-internal channel
-// (the public web-react face carries defineStore only).
-import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
-import type { SessionCell } from '@deepseek-ai/dsh-client-web-react'
+import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SnapshotStore } from '../store/index.ts'
+import { createSnapshotStore } from '../store/index.ts'
 import { SessionManager } from './manager.ts'
 import type { Session } from './session.ts'
 
@@ -216,7 +214,9 @@ export class SessionsService {
       fiber,
       ctx,
       binding: { sessionId: id, session, ctx },
-      cell: { sessionId: id, useSession: session.useSelector },
+      // Bare source form (store migration): the Session object IS the
+      // observable; the React side binds the useSession hook per cell.
+      cell: { sessionId: id, session },
     }
     this.scopes.set(id, record)
     return record

+ 7 - 6
packages/client/runtime/src/client/sessions/session.ts

@@ -7,8 +7,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
 import { transportError } from '@deepseek-ai/dsh-client-connection/client'
-import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
-import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
+import type { ObservableSnapshot } from '../store/index.ts'
 import type {
   ConversationNode, ConversationSnapshot, OpenState, PendingInteraction, PromptError, RunningToolCall,
 } from './conversation.ts'
@@ -19,11 +18,13 @@ import { PartialAccumulator } from './partial.ts'
 /** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */
 export const PAGE_MESSAGES = 50
 
-/** Per-session state owner: event window + fold + partial, snapshot out via uSES (see the web client architecture RFC). */
+/**
+ * Per-session state owner: event window + fold + partial, snapshot out via
+ * subscribe/getSnapshot (see the web client architecture RFC). Bare source
+ * only (store migration): the React machinery binds the per-cell useSession
+ * hook at its own seam — no selector hook member lives on the data layer.
+ */
 export class Session implements ObservableSnapshot<ConversationSnapshot> {
-  /** Typed selector hook bound to this instance (the SessionBinding `useSession` source). */
-  readonly useSelector: SnapshotSelectorHook<ConversationSnapshot> = bindSnapshotSelector(this)
-
   // ---- Window and derived state (all private; the snapshot is the only read surface) ----
   private events: SessionEvent[] = []
   /** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).

+ 11 - 17
packages/client/web-react/src/store/index.ts → packages/client/runtime/src/client/store/index.ts

@@ -3,9 +3,11 @@
  * rafFlush middleware + opt-in persist + dev freeze) plus the declarative
  * shell over it: {@link defineStore} bakes an init/persist/actions literal
  * into a {@link StoreHandle}, the registration-side store seat of the slot
- * terminal design (§4). The engine ({@link createSnapshotStore}) stays the
- * substrate for framework data (runtime sessions/loader/i18n); business
- * plugins declare stores through defineStore only.
+ * terminal design (§4). Lives in the React-free runtime (store-migration
+ * ruling: the data layer owns its engine; web-react is shell-only React
+ * glue): engine products are bare observables — subscribe/getSnapshot/
+ * update/set, NO selector hook. Hook synthesis is web-react's (the one
+ * uSES bridge, cached per source at the binding site).
  */
 import { createStore, type StoreApi } from 'zustand/vanilla'
 import { subscribeWithSelector } from 'zustand/middleware'
@@ -14,10 +16,9 @@ import { produce } from 'immer'
 import type {
   ActionsDecl, BakedActions, StoreHandle, StoreInstance, StoreSpec,
 } from '@deepseek-ai/dsh-client-ui-slots'
-import { bindSnapshotSelector } from '../bind.ts'
 
-// Store contract types are ui-slots authority (wave 1); this module re-exports
-// them beside the engine so '/store' consumers get one import surface.
+// Store contract types are ui-slots authority; re-exported beside the engine
+// so store consumers get one import surface.
 export type {
   ActionsDecl, BakedActions, BoundActions, StoreFactory, StoreHandle, StoreInstance, StoreSpec,
 } from '@deepseek-ai/dsh-client-ui-slots'
@@ -25,7 +26,7 @@ export type {
 /** Minimal observable snapshot source: Session objects and snapshot stores both satisfy it. */
 export interface ObservableSnapshot<T> { getSnapshot(): T; subscribe(fn: () => void): () => void }
 
-/** Writable snapshot store with an attached typed selector hook. */
+/** Writable snapshot store (bare data face; React selector hooks are synthesized in web-react). */
 export interface SnapshotStore<T> extends ObservableSnapshot<T> {
   /**
    * Mutate the state through an immer draft.
@@ -37,14 +38,11 @@ export interface SnapshotStore<T> extends ObservableSnapshot<T> {
    * @param next - next state.
    */
   set(next: T): void
-  readonly useSelector: SnapshotSelectorHook<T>
 }
 
-/** Typed selector hook: equality defaults to Object.is; pass shallowEqual for object slices. */
-export type SnapshotSelectorHook<T> = <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S
-
 /**
- * Shallow equality for selector slices (re-export of zustand/shallow semantics).
+ * Shallow equality for selector slices (zustand/shallow semantics; travels
+ * with the engine so hook consumers need no zustand dependency).
  * @param a - left value.
  * @param b - right value.
  * @returns whether the values are shallowly equal.
@@ -104,7 +102,7 @@ export function createSnapshotStore<T>(
     }
   }
 
-  const store: SnapshotStore<T> = {
+  return {
     getSnapshot: () => api.getState(),
     subscribe: fn => subscribe(fn),
     update: (mutator) => {
@@ -115,10 +113,7 @@ export function createSnapshotStore<T>(
     set: (next) => {
       api.setState(devFreeze(next), true)
     },
-    useSelector: undefined as unknown as SnapshotSelectorHook<T>,
   }
-  ;(store as { useSelector: SnapshotSelectorHook<T> }).useSelector = bindSnapshotSelector(store)
-  return store
 }
 
 /**
@@ -230,7 +225,6 @@ export function defineStore<T, A extends ActionsDecl<T>>(
         actions[key] = (...params: unknown[]) => { store.update((draft) => { mutate(draft, ...params) }) }
       }
       return {
-        useSelector: store.useSelector,
         actions: actions as BakedActions<T, A>,
         getSnapshot: () => store.getSnapshot(),
         subscribe: fn => store.subscribe(fn),

+ 0 - 0
packages/client/web-react/src/env.d.ts → packages/client/runtime/src/env.d.ts


+ 4 - 2
packages/client/runtime/tests/sessions-service.spec.ts

@@ -155,13 +155,15 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
 })
 
 describe('cell (render-layer session kit)', () => {
-  it('resolves an identity-stable {sessionId, useSession} pair; unknown ids yield undefined', async () => {
+  it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
     const b = bench()
     await feedList(b, [{ id: 's1' }])
     const cell = b.svc.cell('s1')
     expect(cell).toBeDefined()
     expect(cell?.sessionId).toBe('s1')
-    expect(cell?.useSession).toBe(b.svc.manager.get(sid('s1')).useSelector)
+    // Bare-source form (store migration): the cell carries the Session
+    // observable itself; hook binding happens in the React machinery.
+    expect(cell?.session).toBe(b.svc.manager.get(sid('s1')))
     expect(b.svc.cell('s1')).toBe(cell)
     expect(b.svc.cell('ghost')).toBeUndefined()
   })

+ 10 - 4
packages/client/runtime/tests/slots-service.spec.ts

@@ -49,9 +49,10 @@ async function boot(): Promise<Bench> {
   return { ctx, svc, erased: svc as unknown as ErasedService }
 }
 
-/** Engine-shaped instance stub (the arbitrated persist face: scope-keyed create + clearPersisted). */
+/** Engine-shaped instance stub (bare-source form: subscribe/getSnapshot + baked actions + clearPersisted). */
 interface FakeInstance {
-  useSelector: () => undefined
+  getSnapshot: () => undefined
+  subscribe: () => () => void
   actions: Record<string, never>
   clearPersisted: ReturnType<typeof vi.fn>
 }
@@ -61,7 +62,10 @@ function fakeHandle() {
   const created: FakeInstance[] = []
   const handle = {
     create: vi.fn((_scopeKey?: string): FakeInstance => {
-      const instance: FakeInstance = { useSelector: () => undefined, actions: {}, clearPersisted: vi.fn() }
+      const instance: FakeInstance = {
+        getSnapshot: () => undefined, subscribe: () => () => undefined,
+        actions: {}, clearPersisted: vi.fn(),
+      }
       created.push(instance)
       return instance
     }),
@@ -91,7 +95,9 @@ function fakeSessions() {
   const state = { ids: [], byId: {}, current: undefined as string | undefined }
   return {
     list: { getSnapshot: () => state, subscribe: () => () => undefined },
-    cell: (id: string) => (id === 'known' ? { sessionId: id, useSession: () => undefined } : undefined),
+    cell: (id: string) => (id === 'known'
+      ? { sessionId: id, session: { getSnapshot: () => undefined, subscribe: () => () => undefined } }
+      : undefined),
   }
 }
 

+ 1 - 1
packages/client/web-react/tests/store.spec.ts → packages/client/runtime/tests/store.spec.ts

@@ -1,5 +1,5 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { createSnapshotStore, defineStore, shallowEqual } from '@deepseek-ai/dsh-client-web-react/store'
+import { createSnapshotStore, defineStore, shallowEqual } from '../src/client/store/index.ts'
 
 interface State {
   a: { n: number }

+ 15 - 1
packages/client/tsdown.client.ts

@@ -36,7 +36,6 @@ export const CLIENT_EXTERNALS = [
   'cordis',
   '@deepseek-ai/dsh-client-ui-slots',
   '@deepseek-ai/dsh-client-web-react',
-  '@deepseek-ai/dsh-client-web-react/store',
   '@deepseek-ai/dsh-client-ui-primitives',
   '@deepseek-ai/dsh-client-connection/client',
   '@deepseek-ai/dsh-client-runtime/client',
@@ -81,6 +80,21 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
     dts: false,
     clean: false,
     external: CLIENT_EXTERNALS,
+    // Browser bundles inline node-idiom deps (zustand/immer read
+    // process.env.NODE_ENV; zustand's esm build also probes
+    // import.meta.env.MODE, which a CJS output cannot carry — rolldown flags
+    // EMPTY_IMPORT_META). vite defined both on the seed path; tsdown inlining
+    // needs the substitutions here or the factory throws ReferenceError at
+    // boot / the build gate reds. Both keys honor the build's NODE_ENV so a
+    // dev build keeps the dev-branch semantics; artifacts default to production.
+    // The bare `import.meta.env` key is required alongside the precise MODE
+    // key: zustand probes `import.meta.env ? import.meta.env.MODE : ...`, and
+    // the truthiness probe would otherwise survive as an empty import.meta.
+    define: {
+      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
+      'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
+      'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
+    },
     // tsdown auto-externalizes package dependencies; anything NOT in the
     // loader module table must inline instead (wire/type layers, zod, clsx —
     // every non-shared dep). A require() the table cannot answer is a

+ 0 - 1
packages/client/ui-conversation/package.json

@@ -39,7 +39,6 @@
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
-    "@deepseek-ai/dsh-client-web-react": "workspace:^",
     "clsx": "^2.0.0",
     "react": "^18.2.0"
   },

+ 2 - 2
packages/client/ui-conversation/src/client/chat/ChatView.tsx

@@ -16,7 +16,7 @@ import {
 import type {
   ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode,
 } from '@deepseek-ai/dsh-client-runtime/client'
-import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
+import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ConvViewProps, SelectionTarget, Translate } from '../contract/views.ts'
 import type { ToolViewProps } from '../contract/toolview.ts'
@@ -38,7 +38,7 @@ const FOLLOW_THRESHOLD = 24
 
 type OpenDetails = (target: SelectionTarget) => void
 
-/** web-react's UseSession is deliberately wide (dependency direction); the
+/** ui-slots' UseSession is deliberately wide (dependency direction); the
  *  chat view narrows once to the runtime snapshot the binding actually feeds. */
 type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
 

+ 1 - 1
packages/client/ui-conversation/src/client/chat/StatsLine.tsx

@@ -7,7 +7,7 @@
 
 import { memo, useMemo } from 'react'
 import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
-import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
+import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ChromeProps } from '../contract/views.ts'
 import css from './StatsLine.module.css'
 

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

@@ -6,7 +6,7 @@
  * implementation files import this, never each other.
  */
 import type { FC } from 'react'
-import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
+import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
 import type { CallId, Translate } from './views.ts'
 

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

@@ -6,7 +6,7 @@
  * import this, never each other.
  */
 import type { FC } from 'react'
-import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
+import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 
 /**

+ 1 - 1
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx

@@ -7,8 +7,8 @@
 
 import { useMemo, useSyncExternalStore, type ReactNode } from 'react'
 import clsx from 'clsx'
+import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
-import { shallowEqual } from '@deepseek-ai/dsh-client-web-react'
 import type { ConversationSlotProps } from '../contract/slots.ts'
 import type { ConvViewProps, ViewEntry } from '../contract/views.ts'
 import { InputBar } from './InputBar.tsx'

+ 1 - 1
packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx

@@ -5,8 +5,8 @@
 // share the store seat exists for) and derives the call material from the
 // session snapshot — no data of its own.
 
+import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
-import { shallowEqual } from '@deepseek-ai/dsh-client-web-react'
 import type { DetailsSlotProps } from '../contract/slots.ts'
 import css from './DetailsPanel.module.css'
 

+ 1 - 1
packages/client/ui-conversation/src/client/stores.ts

@@ -9,7 +9,7 @@
  * Module exports the factory only — a module-level handle would pin identity
  * in the module cache (a de-facto singleton surviving plugin reloads).
  */
-import { defineStore } from '@deepseek-ai/dsh-client-web-react'
+import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ChatStoreState, SelectionTarget, ViewId } from './contract/views.ts'
 
 /**

+ 2 - 2
packages/client/ui-conversation/tests/apply-inject.spec.tsx

@@ -10,10 +10,10 @@
 import { Context } from 'cordis'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup } from '@testing-library/react'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/src/store/index.ts'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
-import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
+import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {
   ConversationInjected, DetailsInjected, EmptyStateInjected,

+ 1 - 1
packages/client/ui-conversation/tests/chat-apply.spec.tsx

@@ -8,7 +8,7 @@
 
 import { Context } from 'cordis'
 import { describe, expect, it, vi } from 'vitest'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/src/store/index.ts'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
 import { apply, inject, ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'

+ 3 - 3
packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx

@@ -9,8 +9,8 @@ import { cleanup, render } from '@testing-library/react'
 import { act } from '@testing-library/react'
 import type { SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
 import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
-import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
-import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
+import { hookOf } from './hook.ts'
+import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
 import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { ToolViewProps, Translate } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { MessageItem } from '../src/client/chat/MessageItem.tsx'
@@ -85,7 +85,7 @@ describe('small branch tails', () => {
     }
     const source = { getSnapshot: () => snap, subscribe: () => () => {} }
     const view = render(
-      <StatsLine sessionId={SID} useSession={bindSnapshotSelector(source) as unknown as UseSession} />,
+      <StatsLine sessionId={SID} useSession={hookOf(source) as unknown as UseSession} />,
     )
     expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy()
   })

+ 3 - 3
packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx

@@ -8,8 +8,8 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react'
 import type {
   AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode,
 } from '@deepseek-ai/dsh-client-runtime/client'
-import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
-import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
+import { hookOf } from './hook.ts'
+import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ChromeProps, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { StatsLine, deriveStats } from '../src/client/chat/StatsLine.tsx'
@@ -78,7 +78,7 @@ describe('deriveStats', () => {
 
 describe('StatsLine', () => {
   function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): ChromeProps {
-    return { sessionId: SID, useSession: bindSnapshotSelector(source) as unknown as UseSession }
+    return { sessionId: SID, useSession: hookOf(source) as unknown as UseSession }
   }
 
   it('renders the joined stats row and hides with zero steps', () => {

+ 1 - 1
packages/client/ui-conversation/tests/chat-tool-row.spec.tsx

@@ -4,7 +4,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
 
 afterEach(cleanup)
 import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
-import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
+import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
 import { classifyTool, toolRowModel } from '../src/client/contract/tool-call-model.ts'
 import { ToolRow } from '../src/client/chat/ToolRow.tsx'
 import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'

+ 4 - 4
packages/client/ui-conversation/tests/chat-view.spec.tsx

@@ -9,8 +9,8 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react'
 import type {
   AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode, UserMessageNode,
 } from '@deepseek-ai/dsh-client-runtime/client'
-import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
-import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
+import { hookOf } from './hook.ts'
+import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ConvViewProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { createChatStore } from '../src/client/stores.ts'
@@ -79,8 +79,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
   const chat = createChatStore().create()
   const props: ConvViewProps = {
     sessionId: SID,
-    useSession: bindSnapshotSelector(source) as unknown as UseSession,
-    useStore: chat.useSelector,
+    useSession: hookOf(source) as unknown as UseSession,
+    useStore: hookOf(chat),
     actions: { openDetails, loadOlder },
   }
   const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }

+ 1 - 1
packages/client/ui-conversation/tests/coverage-tails.spec.tsx

@@ -8,7 +8,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, render } from '@testing-library/react'
 import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
 import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
-import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
+import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
 import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { ConversationService, Translate, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { apply as nodeApply } from '../src/index.ts'

+ 8 - 22
packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx

@@ -8,12 +8,11 @@
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, render } from '@testing-library/react'
-import { Context } from 'cordis'
-import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/src/store/index.ts'
-import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
+import { hookOf } from './hook.ts'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
-import { apply, inject, ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { createChatStore } from '../src/client/stores.ts'
 import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
@@ -32,19 +31,6 @@ function snapshotBase(): ConversationSnapshot {
   } as ConversationSnapshot
 }
 
-describe('apply need() and cwd cache', () => {
-  it('apply fails loud when a required service is absent', () => {
-    // Call apply directly (no fiber machinery): need('sessions') on a bare
-    // context throws synchronously — the loud-failure branch without the
-    // fiber runner's internal rejection surface. Mount semantics (inject
-    // gating) are covered by the full bench in apply-inject.spec.
-    void inject
-    const ctx = new Context()
-    expect(() => { (apply as (c: Context) => void)(ctx) }).toThrow(/sessions service unavailable/)
-  })
-
-})
-
 describe('render branch tails', () => {
   it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => {
     const view = render(
@@ -68,7 +54,7 @@ describe('render branch tails', () => {
     }
     const source = { getSnapshot: () => snap, subscribe: () => () => {} }
     const view = render(
-      <StatsLine sessionId={SID} useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
+      <StatsLine sessionId={SID} useSession={hookOf(source) as unknown as UseSession<ConversationSnapshot>} />,
     )
     expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
   })
@@ -90,9 +76,9 @@ describe('render branch tails', () => {
     const view = render(
       <DetailsPanel
         sessionId={SID}
-        useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
-        useSessions={emptyList.useSelector}
-        useStore={chat.useSelector}
+        useSession={hookOf({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
+        useSessions={hookOf(emptyList)}
+        useStore={hookOf(chat)}
         actions={chat.actions}
         closeDetails={vi.fn()}
       />,

+ 26 - 0
packages/client/ui-conversation/tests/hook.ts

@@ -0,0 +1,26 @@
+/**
+ * Test-local selector-hook binder: the engine carries no hook since the store
+ * migration (runtime is React-free); the renderer binds in production, specs
+ * bind here. Delegates to web-react's bindSnapshotSelector SOURCE (same
+ * with-selector uSES shim as production, so selector-level render economics —
+ * a top-level snapshot swap with an unchanged slice does NOT re-render — hold
+ * in Profiler-count specs). Source-relative import: the package dependency
+ * edge to web-react is gone (store migration §7); tests reach the sibling
+ * package the same way they reach their own src internals.
+ */
+import { bindSnapshotSelector } from '../../web-react/src/bind.ts'
+
+/** Minimal observable source (engine stores and scripted fakes both satisfy it). */
+export interface HookSource<T> {
+  getSnapshot(): T
+  subscribe(fn: () => void): () => void
+}
+
+/**
+ * Bind a selector hook over a snapshot source.
+ * @param src - the source.
+ * @returns a SnapshotSelectorHook-shaped hook.
+ */
+export function hookOf<T>(src: HookSource<T>) {
+  return bindSnapshotSelector<T>(src)
+}

+ 2 - 2
packages/client/ui-conversation/tests/selection-survival.spec.ts

@@ -75,8 +75,8 @@ function storeFor(b: Bench, slot: 'conversation' | 'details', sessionId: Session
 }
 
 /** The host face is only built at renderSlot time; install a stub renderer once to reach it. */
-function renderHost(b: Bench): import('@deepseek-ai/dsh-client-web-react').SlotRendererHost {
-  const captured = (b as unknown as { _host?: import('@deepseek-ai/dsh-client-web-react').SlotRendererHost })
+function renderHost(b: Bench): import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost {
+  const captured = (b as unknown as { _host?: import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost })
   if (captured._host === undefined) {
     b.slots.install({
       renderRoot: (host) => {

+ 12 - 12
packages/client/ui-conversation/tests/skeleton-branches.spec.tsx

@@ -7,9 +7,9 @@
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
-import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/src/store/index.ts'
-import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
+import { hookOf } from './hook.ts'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
 // Export discipline: packages/client/AGENTS.md.
@@ -49,7 +49,7 @@ function listHook(rows: { id: string; title: string; cwd?: string; parentId?: st
     }])),
     current: undefined,
   } as SessionListState)
-  return store.useSelector
+  return hookOf(store)
 }
 
 describe('ConversationRoot branches', () => {
@@ -66,9 +66,9 @@ describe('ConversationRoot branches', () => {
     const view = render(
       <ConversationRoot
         sessionId={SID}
-        useSession={bindSnapshotSelector(sessionSource(over?.snapshot)) as unknown as UseSession<ConversationSnapshot>}
+        useSession={hookOf(sessionSource(over?.snapshot)) as unknown as UseSession<ConversationSnapshot>}
         useSessions={listHook(over?.rows ?? [])}
-        useStore={chat.useSelector}
+        useStore={hookOf(chat)}
         actions={chat.actions}
         views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
         send={vi.fn()}
@@ -124,9 +124,9 @@ describe('ConversationRoot branches', () => {
     const view = render(
       <ConversationRoot
         sessionId={SID}
-        useSession={bindSnapshotSelector(sessionSource()) as unknown as UseSession<ConversationSnapshot>}
+        useSession={hookOf(sessionSource()) as unknown as UseSession<ConversationSnapshot>}
         useSessions={listHook([])}
-        useStore={chat.useSelector}
+        useStore={hookOf(chat)}
         actions={chat.actions}
         views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
         send={vi.fn()}
@@ -147,9 +147,9 @@ describe('DetailsPanel branches', () => {
     return render(
       <DetailsPanel
         sessionId={SID}
-        useSession={bindSnapshotSelector(sessionSource(snapshot)) as unknown as UseSession<ConversationSnapshot>}
+        useSession={hookOf(sessionSource(snapshot)) as unknown as UseSession<ConversationSnapshot>}
         useSessions={listHook([])}
-        useStore={chat.useSelector}
+        useStore={hookOf(chat)}
         actions={chat.actions}
         closeDetails={vi.fn()}
       />,
@@ -183,9 +183,9 @@ describe('DetailsPanel branches', () => {
     const view = render(
       <DetailsPanel
         sessionId={SID}
-        useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
+        useSession={hookOf(source) as unknown as UseSession<ConversationSnapshot>}
         useSessions={listHook([])}
-        useStore={chat.useSelector}
+        useStore={hookOf(chat)}
         actions={chat.actions}
         closeDetails={vi.fn()}
       />,

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

@@ -11,9 +11,9 @@
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import type { FC } from 'react'
-import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/src/store/index.ts'
-import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
+import { hookOf } from './hook.ts'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
 // Export discipline: packages/client/AGENTS.md.
@@ -42,7 +42,7 @@ function fakeSession(init: Partial<FakeSnapshot> = {}) {
   const store = createSnapshotStore<FakeSnapshot>({
     nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init,
   })
-  return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
+  return { store, useSession: hookOf(store) as unknown as UseSession<ConversationSnapshot> }
 }
 
 /** Sessions-list stub: the standard useSessions hook over a snapshot store. */
@@ -56,7 +56,7 @@ function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?
     }])),
     current: undefined,
   } as SessionListState)
-  return { store, useSessions: store.useSelector }
+  return { store, useSessions: hookOf(store) }
 }
 
 describe('EmptyState', () => {
@@ -114,7 +114,7 @@ describe('ConversationRoot', () => {
         sessionId={sid('s1')}
         useSession={useSession}
         useSessions={useSessions}
-        useStore={chat.useSelector}
+        useStore={hookOf(chat)}
         actions={chat.actions}
         views={{
           list: () => views,
@@ -199,7 +199,7 @@ describe('DetailsPanel', () => {
         sessionId={sid('s1')}
         useSession={useSession}
         useSessions={useSessions}
-        useStore={chat.useSelector}
+        useStore={hookOf(chat)}
         actions={chat.actions}
         closeDetails={closeDetails}
       />)

+ 0 - 1
packages/client/ui-layout/package.json

@@ -36,7 +36,6 @@
   "dependencies": {
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
-    "@deepseek-ai/dsh-client-web-react": "workspace:^",
     "react": "^18.2.0"
   },
   "peerDependencies": {

+ 8 - 7
packages/client/ui-layout/src/client/AppFrame.tsx

@@ -4,15 +4,16 @@
  * details), the drag handles (pointer capture + rAF throttle), the concession
  * chain (columns.ts), and the child-slot render decisions: the sidebar slot
  * renders HERE with live parameters from the concession solve, and the
- * session pair renders under the framework-wired SessionProvider (render-prop
- * form; session slots get sessionId as a framework-standard prop, so the
- * owner shares stay empty). Pure component: everything arrives through the
- * four prop shares — zero cordis imports, zero self-made hooks.
+ * session pair renders under the SessionProvider standard seat (render-prop
+ * form, injected by the renderer because the children declaration contains
+ * session-scope slots; session slots get sessionId as a framework-standard
+ * prop, so the owner shares stay empty). Pure component: everything arrives
+ * through the four prop shares — zero cordis or framework imports, zero
+ * self-made hooks.
  */
 import { useCallback, useEffect, useRef, useState } from 'react'
 import type { ReactNode } from 'react'
 import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
-import { SessionProvider } from '@deepseek-ai/dsh-client-web-react'
 import { computeColumns } from './columns.ts'
 import type { createLayoutStore } from './stores.ts'
 import css from './AppFrame.module.css'
@@ -78,8 +79,8 @@ function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: num
   )
 }
 
-/** The three-column frame (see module doc). */
-export function AppFrame({ useStore, actions, renderSlot }: AppFrameProps) {
+/** The three-column frame (see module doc). SessionProvider arrives as a standard seat (declaring a session-scope child summons it — no framework import). */
+export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: AppFrameProps) {
   const panels = useStore((s) => s)
   const frameRef = useRef<HTMLDivElement | null>(null)
   const [viewport, setViewport] = useState(() => window.innerWidth)

+ 1 - 1
packages/client/ui-layout/src/client/stores.ts

@@ -7,7 +7,7 @@
  * derives its PropsStore share from the return type, and the service face
  * receives the bound actions through the registration's inject hook.
  */
-import { defineStore } from '@deepseek-ai/dsh-client-web-react'
+import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
 import {
   clampWidth, DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN,
   SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,

+ 23 - 19
packages/client/ui-layout/tests/app-frame.spec.tsx

@@ -12,24 +12,22 @@
  */
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, render } from '@testing-library/react'
-import type { ReactNode } from 'react'
+import { useSyncExternalStore } from 'react'
 import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
 import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
 import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts'
 
-// Session-mode switch for the SessionProvider stub (hoisted above the mock).
-const sessionMode = vi.hoisted(() => ({ current: true }))
+// Session-mode switch for the SessionProvider stub prop.
+const sessionMode = { current: true }
+
+// Render-prop contract stub fed through the standard seat prop (the renderer
+// injects the real one in production): session mode runs children(id), empty
+// mode runs the empty branch — the frame must work against exactly this
+// shape. Typed as the seat's own component type so the branded sessionId
+// parameter stays contract-checked.
+const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) =>
+  sessionMode.current ? <>{children('s-test' as Parameters<typeof children>[0])}</> : <>{empty?.() ?? null}</>
 
-vi.mock('@deepseek-ai/dsh-client-web-react', async (importOriginal) => {
-  const mod = await importOriginal<object>()
-  return {
-    ...mod,
-    // Render-prop contract stub: session mode runs children(id), empty mode
-    // runs the empty branch — the frame must work against exactly this shape.
-    SessionProvider: ({ children, empty }: { children: (id: string) => ReactNode; empty?: () => ReactNode }) =>
-      sessionMode.current ? <>{children('s-test')}</> : <>{empty?.() ?? null}</>,
-  }
-})
 
 /** Observer stub: captures the callback so tests can fire resizes manually. */
 let fireResize: (() => void) | null = null
@@ -43,6 +41,11 @@ class ResizeObserverStub {
 
 let frameWidth = 1920
 
+/** Minimal selector hook over an engine instance (the engine carries no hook since the store migration; the renderer binds in production, the spec binds here). */
+function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
+  return <S,>(sel: (s: T) => S): S => sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot))
+}
+
 function mountFrame() {
   window.innerWidth = frameWidth // first-render viewport source before the observer fires
   const instance = createLayoutStore().create()
@@ -58,10 +61,11 @@ function mountFrame() {
   const useSessions = ((sel: (s: unknown) => unknown) => sel({ ids: [], byId: {} })) as never
   const utils = render(
     <AppFrame
-      useStore={instance.useSelector}
+      useStore={hookOf(instance) as never}
       actions={instance.actions}
       renderSlot={renderSlot}
       useSessions={useSessions}
+      SessionProvider={SessionProviderStub}
     />,
   )
   const frame = utils.container.firstElementChild as HTMLElement
@@ -160,7 +164,7 @@ describe('AppFrame', () => {
     expect(tracks(frame)).toEqual([300, 310])
     const handles = frame.querySelectorAll('[class*="handle"]')
     drag(handles[1]!, 940, 950) // shrink by 10 from the rendered width
-    expect(instance.store.getSnapshot().details).toBe(300)
+    expect(instance.getSnapshot().details).toBe(300)
   })
 
   it('details column stays mounted at zero width', () => {
@@ -195,14 +199,14 @@ describe('AppFrame — guard branches', () => {
   it('pointer moves without capture are ignored (no width write)', () => {
     const { frame, instance } = mountFrame()
     const handle = frame.querySelectorAll('[class*="handle"]')[0]!
-    const before = instance.store.getSnapshot().sidebar
+    const before = instance.getSnapshot().sidebar
     // Move + up without a preceding pointerdown: hasPointerCapture is false.
     act(() => {
       handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 9, clientX: 500, bubbles: true }))
       vi.advanceTimersByTime(20)
       handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 9, clientX: 500, bubbles: true }))
     })
-    expect(instance.store.getSnapshot().sidebar).toBe(before)
+    expect(instance.getSnapshot().sidebar).toBe(before)
   })
 
   it('two moves inside one frame coalesce through the pending rAF', () => {
@@ -217,7 +221,7 @@ describe('AppFrame — guard branches', () => {
       vi.advanceTimersByTime(20)
     })
     act(() => { handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 340, bubbles: true })) })
-    expect(instance.store.getSnapshot().sidebar).toBe(340)
+    expect(instance.getSnapshot().sidebar).toBe(340)
   })
 
   it('pointerup with a pending rAF cancels it and commits the final position', () => {
@@ -229,7 +233,7 @@ describe('AppFrame — guard branches', () => {
       // No timer advance: the rAF is still pending when pointerup arrives.
       handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 360, bubbles: true }))
     })
-    expect(instance.store.getSnapshot().sidebar).toBe(360)
+    expect(instance.getSnapshot().sidebar).toBe(360)
   })
 
   it('zero-width resize reports are ignored (display:none window)', () => {

+ 0 - 1
packages/client/ui-sidebar/package.json

@@ -38,7 +38,6 @@
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
-    "@deepseek-ai/dsh-client-web-react": "workspace:^",
     "clsx": "^2.0.0",
     "react": "^18.2.0"
   },

+ 1 - 1
packages/client/ui-sidebar/tests/apply.spec.tsx

@@ -8,7 +8,7 @@
  */
 import { Context } from 'cordis'
 import { describe, expect, it, vi } from 'vitest'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'

+ 11 - 5
packages/client/ui-sidebar/tests/sidebar-root.spec.tsx

@@ -9,13 +9,19 @@
  */
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { act } from 'react'
-// Engine subpath: createSnapshotStore left the public face (wave 3); the
-// engine remains the sanctioned stub source for the standard hooks in tests.
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
+import { act, useSyncExternalStore } from 'react'
+// Engine home: runtime/client since the store migration; the engine carries
+// no hook (runtime is React-free), so the spec binds the selector locally.
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
 import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
 
+/** Minimal selector hook over an engine store (production binding lives in the renderer). */
+function hookOf<T>(src: { getSnapshot(): T; subscribe(fn: () => void): () => void }) {
+  return <S,>(sel: (s: T) => S, _eq?: (a: S, b: S) => boolean): S =>
+    sel(useSyncExternalStore(src.subscribe.bind(src), src.getSnapshot.bind(src)))
+}
+
 const sid = (s: string) => s as SessionId
 
 /** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
@@ -59,7 +65,7 @@ function mount(...summaries: SessionSummary[]) {
     <SidebarRoot
       collapsed={false}
       width={300}
-      useSessions={sessions.useSelector}
+      useSessions={hookOf(sessions)}
       onOpen={onOpen}
       onCreate={onCreate}
       onToggleSidebar={onToggleSidebar}

+ 1 - 1
packages/client/ui-slots/README.md

@@ -13,7 +13,7 @@ One `register({ name, children?, store?, inject?, ...kind }, Component)` call co
 
 The standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`) are declared empty here and merged by the runtime package (same declare-merge pattern as SlotMap keys). Inject factory parameters derive from the declaration (`InjectParams`): session slots get `sessionId`, a declared store appends baked `actions`, nothing else — data access lives in the apply closure's ctx.
 
-The store family (`defineStore` spec in / `StoreHandle<T, A>` out) types the store seat: `init` infers the state schema, `actions` is the complete draft-transform write set, `BakedActions` strips the draft parameter into the callbacks components and inject factories receive. The `defineStore` value implementation lives in web-react (the engine's home) and satisfies the `DefineStore` contract exported here.
+The store family (`defineStore` spec in / `StoreHandle<T, A>` out) types the store seat: `init` infers the state schema, `actions` is the complete draft-transform write set, `BakedActions` strips the draft parameter into the callbacks components and inject factories receive. The `defineStore` value implementation lives in the runtime package (the engine's home) and satisfies the `DefineStore` contract exported here. Engine products and the renderer host contract carry bare snapshot sources (`getSnapshot`/`subscribe`), never React hooks — hook binding is the render machinery's side of the seam; only the props-contract hook type (`SnapshotSelectorHook`) lives here.
 
 `SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot.
 

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

@@ -14,7 +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 { BoundActions, HandleOf, PropsStore, StoreDecl } from './store.ts'
+import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl } from './store.ts'
 
 export * from './store.ts'
 export * from './renderer.ts'
@@ -98,6 +98,32 @@ export type PropsRuntime<K extends keyof SlotMap & string> =
 /** renderSlot dispatch options: keyed dispatch key, list filtering, empty fallback. */
 export interface RenderOpts { entryKey?: string; only?: string; fallback?: ReactNode }
 
+/**
+ * Conversation-session selector hook alias for props contracts. Wide by
+ * default at this dependency-inverted layer; the runtime narrows at its
+ * export seam (`UseSession<ConversationSnapshot>`).
+ */
+export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap>
+
+/** Props of the standard-kit SessionProvider seat (render-prop form). */
+export interface SessionAreaProps {
+  /** No-session body (also covers a current id whose session cannot be resolved). */
+  empty?: (() => ReactNode) | undefined
+  /** Session body; the framework remounts it per session (key=sessionId). */
+  children: (sessionId: SessionIdOf) => ReactNode
+}
+
+/**
+ * The framework-wired session area component (slot terminal design §7):
+ * subscribes to the current-session selection internally (design fiat ① —
+ * selection authority lives with runtime sessions) and switches between the
+ * session body and the empty branch. Delivered as a standard seat to every
+ * entry whose children declaration contains a session-scope slot (the
+ * derivation rides {@link PropsRenderSlots}); the value is injected by the
+ * installed renderer — business code never imports it.
+ */
+export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode
+
 /**
  * Child-slot render share: `renderSlot` statically narrowed to the entry's
  * declared children keys. Delegation is plain props passing (hand
@@ -117,7 +143,12 @@ export type PropsRenderSlots<S extends keyof SlotMap & string> = {
    */
   renderSlot: <K extends S>(key: K, owner: OwnerOf<K>, opts?: RenderOpts) => ReactNode
   readonly __renders?: ((key: S) => void) | undefined
-}
+} & ('session' extends ScopeOf<S>
+  // The SessionProvider seat rides the same source as renderSlot: declaring
+  // a session-scope child is what makes a session area exist, so the seat
+  // derives from the children key set's scopes (renderer injects the value).
+  ? { SessionProvider: SessionProviderComponent }
+  : object)
 
 /**
  * Registration-position component shape: the bare call signature, so composed

+ 19 - 5
packages/client/ui-slots/src/renderer.ts

@@ -16,19 +16,33 @@ export interface HostObservable<T> {
 
 /**
  * Type-erased store instance face at the render seam (the typed twin is
- * {@link StoreInstance}): selector hook plus draft-stripped action callbacks.
- * Typing lands at the component seam via {@link PropsStore}.
+ * {@link StoreInstance}): a bare snapshot source plus the draft-stripped
+ * action callbacks. No React hook crosses this seam — the render machinery
+ * binds `useStore` from the source at its own side (cached per instance);
+ * typing lands at the component seam via {@link PropsStore}.
  */
 export interface StoreInstanceLike {
-  readonly useSelector: unknown
+  /** Current state snapshot (uSES getSnapshot side). */
+  getSnapshot(): unknown
+  /**
+   * Subscribe to state changes (uSES subscribe side).
+   * @param fn - change callback.
+   * @returns unsubscribe.
+   */
+  subscribe(fn: () => void): () => void
+  /** Baked write callbacks (delivered to components as `actions`). */
   readonly actions: Record<string, (...params: never[]) => void>
 }
 
 /** Session standard kit resolved per session id (identity-stable per session scope; a recreated scope yields a new cell). */
 export interface SessionCell {
   sessionId: string
-  /** Bound conversation-snapshot selector hook (wide here; runtime narrows at its export seam). */
-  useSession: unknown
+  /**
+   * Bare conversation-snapshot source (wide here; runtime narrows at its
+   * export seam). The React side binds the `useSession` hook per cell —
+   * hooks never appear on the host contract.
+   */
+  session: HostObservable<unknown>
 }
 
 /** renderSlot dispatch options at the machinery level: keyed dispatch key, list filtering, empty fallback. */

+ 10 - 10
packages/client/ui-slots/src/store.ts

@@ -51,19 +51,19 @@ export interface StoreSpec<T, A extends ActionsDecl<T>> {
 
 /**
  * Live engine instance: the create() product consumed by the render machinery
- * and by component tests (fed straight into props as useStore/actions).
- * Production components and render paths never call create() themselves —
- * instance lifecycle is the framework's.
+ * and by tests. A bare snapshot source plus the baked write set — no React
+ * hook rides the engine product (the engine lives in the React-free runtime);
+ * the render machinery binds the `useStore` hook from this source on its own
+ * side, cached per instance. Production components and render paths never
+ * call create() themselves — instance lifecycle is the framework's.
  */
 export interface StoreInstance<T, A extends ActionsDecl<T>> {
-  /** Selector hook bound to this instance (delivered to components as `useStore`). */
-  readonly useSelector: SnapshotSelectorHook<T>
   /** Baked write callbacks (delivered to components as `actions`). */
   readonly actions: BakedActions<T, A>
-  /** Current state snapshot (test assertions; machinery). */
+  /** Current state snapshot (uSES getSnapshot side; test assertions). */
   getSnapshot(): T
   /**
-   * Subscribe to state changes.
+   * Subscribe to state changes (uSES subscribe side).
    * @param fn - change callback.
    * @returns unsubscribe.
    */
@@ -130,8 +130,8 @@ export type PropsStore<H> = H extends StoreHandle<infer T, infer A>
   : object
 
 /**
- * The defineStore contract (implementation lives in web-react, bound to the
- * snapshot-store engine): spec in, handle out, with T inferred from `init`
- * and the actions table constrained by T.
+ * The defineStore contract (implementation lives in the runtime package,
+ * bound to the snapshot-store engine): spec in, handle out, with T inferred
+ * from `init` and the actions table constrained by T.
  */
 export type DefineStore = <T, A extends ActionsDecl<T>>(spec: StoreSpec<T, A>) => StoreHandle<T, A>

+ 6 - 0
packages/client/ui-slots/tests/type-chain.spec.tsx

@@ -166,6 +166,12 @@ describe('terminal-design type chain', () => {
       acts.setDraft('x')
       // @ts-expect-error wrong payload type
       acts.setDraft(1)
+
+      // SessionProvider seat: derives from a session-scope child declaration.
+      fp.SessionProvider({ empty: () => null, children: () => null })
+      const sideOnly: PropsRenderSlots<'chain.side'> = null as never
+      // @ts-expect-error only root-scope children declared → no SessionProvider seat
+      void sideOnly.SessionProvider
     }
     expect(samples).toBeTypeOf('function')
   })

+ 0 - 1
packages/client/ui-trajectory/package.json

@@ -35,7 +35,6 @@
   "license": "BSD-3-Clause",
   "dependencies": {
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
-    "@deepseek-ai/dsh-client-web-react": "workspace:^",
     "react": "^18.2.0"
   },
   "peerDependencies": {

+ 1 - 1
packages/client/ui-trajectory/src/client/TrajectoryStatsHeader.tsx

@@ -6,7 +6,7 @@
 
 import { memo, useMemo } from 'react'
 import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
-import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
+import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ChromeProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { deriveSpans, deriveSpanStats } from './spans.ts'
 import css from './TrajectoryStatsHeader.module.css'

+ 1 - 1
packages/client/ui-trajectory/src/client/TrajectoryView.tsx

@@ -4,7 +4,7 @@
 
 import { useMemo } from 'react'
 import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
-import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
+import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { deriveSpans } from './spans.ts'
 import css from './views.module.css'

+ 1 - 1
packages/client/ui-trajectory/src/client/WaterfallView.tsx

@@ -4,7 +4,7 @@
 
 import { useMemo } from 'react'
 import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
-import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
+import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { deriveSpans } from './spans.ts'
 import css from './views.module.css'

+ 6 - 6
packages/client/ui-trajectory/tests/views.spec.tsx

@@ -11,9 +11,9 @@ import { Context } from 'cordis'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
 import { createElement, type FC } from 'react'
-import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/src/store/index.ts'
-import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
+import { bindSnapshotSelector } from '../../web-react/src/bind.ts'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
 import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
 // Export discipline: packages/client/AGENTS.md.
@@ -53,7 +53,7 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) {
 function emptySessions() {
   const store = createSnapshotStore<SessionListState>(
     { ids: [], byId: {}, current: undefined } as SessionListState)
-  return store.useSelector
+  return bindSnapshotSelector(store)
 }
 
 /** Chat-view stand-in props for standalone view mounts. */
@@ -62,7 +62,7 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
   return {
     sessionId: SID,
     useSession: fakeSession(nodes).useSession,
-    useStore: chat.useSelector,
+    useStore: bindSnapshotSelector(chat),
     actions: { openDetails: vi.fn(), loadOlder: vi.fn() },
   } as unknown as ConvViewProps
 }
@@ -89,7 +89,7 @@ function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] =
       sessionId={SID}
       useSession={bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>}
       useSessions={emptySessions()}
-      useStore={chat.useSelector}
+      useStore={bindSnapshotSelector(chat)}
       actions={chat.actions}
       views={{
         list: () => svc.views(),

+ 1 - 1
packages/client/web-react/README.md

@@ -1,6 +1,6 @@
 # @deepseek-ai/dsh-client-web-react
 
-ctx↔React machinery for the slot terminal design: createSlotRenderer (the SlotRenderer implementation the shell installs into the runtime SlotsService), SessionProvider (framework-wired render prop over the host's current-session source), defineStore (the declarative store shell over the internal zustand engine), bindSnapshotSelector, useInvoke. The snapshot-store engine (createSnapshotStore) is framework-internal via the `./store` subpath; business plugins declare stores through defineStore only.
+Shell-side React glue for the slot terminal design: createSlotRenderer (the SlotRenderer implementation the shell installs into the runtime SlotsService), SessionProvider (framework-wired render prop, also injected as a standard seat to entries declaring session-scope children), bindSnapshotSelector (the one hook constructor — hosts and engines traffic in bare observable sources; every hook binds here, cached per source), useInvoke. The snapshot-store engine and defineStore live in runtime (store relocation); business plugins depend on ui-slots types only, never on this package.
 
 ## Model Experience
 

+ 2 - 9
packages/client/web-react/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-web-react",
-  "description": "ctx-to-React glue: createSnapshotStore (zustand engine), bindSnapshotSelector, SessionProvider, scopedSlots outlet, useInvoke",
+  "description": "Shell-side React glue: createSlotRenderer, SessionProvider, bindSnapshotSelector (uSES bridge), useInvoke",
   "version": "0.0.1",
   "private": true,
   "type": "module",
@@ -15,20 +15,14 @@
       "types": "./lib/types/invariant.d.ts",
       "default": "./lib/invariant.js"
     },
-    "./store": {
-      "types": "./lib/types/store/index.d.ts",
-      "default": "./lib/store/index.js"
-    },
     "./src/*": "./src/*",
     "./package.json": "./package.json"
   },
   "license": "BSD-3-Clause",
   "dependencies": {
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
-    "immer": "^10.1.1",
     "react": "^18.2.0",
-    "use-sync-external-store": "1.2.0",
-    "zustand": "~4.4.7"
+    "use-sync-external-store": "1.2.0"
   },
   "peerDependencies": {
     "@deepseek-ai/dsh-invariants": "^0.0.1",
@@ -42,7 +36,6 @@
   "files": [
     "lib/index.js",
     "lib/invariant.js",
-    "lib/store/index.js",
     "lib/types/**/*.d.ts",
     "lib/types/**/*.d.ts.map",
     "src"

+ 8 - 6
packages/client/web-react/src/bind.ts

@@ -1,19 +1,21 @@
 /**
- * uSES bridge: turns any {@link ObservableSnapshot} into a typed selector
- * hook. Client-side-rendered only, so no server snapshot is wired.
+ * uSES bridge: turns any bare observable snapshot source into a typed
+ * selector hook. Client-side-rendered only, so no server snapshot is wired.
+ * This is the ONE hook constructor in the client stack — engines and hosts
+ * traffic in bare sources; binding happens on the React side.
  */
 import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector.js'
-import type { ObservableSnapshot, SnapshotSelectorHook } from './store/index.ts'
+import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 
 /**
- * Bind an observable snapshot source to a typed uSES selector hook.
+ * Bind a bare observable source to a typed uSES selector hook.
  * subscribe/getSnapshot are captured once per source into stable closures
  * (also re-binds `this` for method-based sources), so components never
  * resubscribe across renders. Equality defaults to Object.is.
- * @param w - snapshot source (Session object or snapshot store).
+ * @param w - snapshot source (engine store, Session object, store instance).
  * @returns the selector hook.
  */
-export function bindSnapshotSelector<T>(w: ObservableSnapshot<T>): SnapshotSelectorHook<T> {
+export function bindSnapshotSelector<T>(w: HostObservable<T>): SnapshotSelectorHook<T> {
   const subscribe = (fn: () => void) => w.subscribe(fn)
   const getSnapshot = () => w.getSnapshot()
   return function useSelector<S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean): S {

+ 9 - 16
packages/client/web-react/src/index.ts

@@ -1,22 +1,15 @@
 /**
- * ctx-to-React machinery (slot terminal design §8): createSlotRenderer (the
+ * Shell-side React glue (slot terminal design §8): createSlotRenderer (the
  * install-seam implementation), SessionProvider (framework-wired render
- * prop), the defineStore shell, and useInvoke. Contract types (SlotRenderer
- * family, store family, four-share props) are ui-slots authority — this face
- * re-exports the ones its own values traffic in. The snapshot-store ENGINE
- * (createSnapshotStore) is framework-internal — runtime/i18n reach it through
- * the './store' subpath; business plugins declare stores via defineStore
- * only. React contexts stay in-package: business components see none.
+ * prop, also delivered as a standard seat to session-area entries),
+ * bindSnapshotSelector (the one hook constructor), and useInvoke. The
+ * snapshot-store engine and defineStore live in runtime (store relocation);
+ * contract types are ui-slots authority — this face re-exports only what its
+ * own values traffic in. React contexts stay in-package: business components
+ * see none.
  */
-import type { SnapshotSelectorHook } from './store/index.ts'
+import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 
-// -- store: the declarative shell is public; the engine stays off this face --
-export type {
-  ActionsDecl, BakedActions, BoundActions, EngineStoreHandle, EngineStoreInstance,
-  ObservableSnapshot, SnapshotSelectorHook, SnapshotStore,
-  StoreFactory, StoreHandle, StoreInstance, StoreSpec,
-} from './store/index.ts'
-export { defineStore, shallowEqual } from './store/index.ts'
 export { bindSnapshotSelector } from './bind.ts'
 
 /**
@@ -29,7 +22,7 @@ export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap
 
 // -- renderer: the install-seam implementation; contract lives in ui-slots --
 export type {
-  HostObservable, RenderOpts, SessionCell,
+  HostObservable, RenderOpts, SessionCell, SnapshotSelectorHook,
   SlotRenderer, SlotRendererHost, StoreInstanceLike,
 } from '@deepseek-ai/dsh-client-ui-slots'
 export { SlotOwnershipError, StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots'

+ 17 - 7
packages/client/web-react/src/scoped-slots.tsx

@@ -19,7 +19,7 @@ import {
   type StoredEntry,
 } from '@deepseek-ai/dsh-client-ui-slots'
 import {
-  HostContext, SlotAssemblyError, observableHook, useHost, useSessionCell,
+  HostContext, SessionProvider, SlotAssemblyError, observableHook, useHost, useSessionCell,
 } from './session-provider.tsx'
 
 type InjectedProps = Record<string, unknown>
@@ -120,26 +120,36 @@ class SlotErrorBoundary extends Component<
 
 /**
  * Standard-kit synthesis shared by both scope branches: the global
- * useSessions hook, the store pair when declared, and the renderSlot binding
- * when children are declared. Every member is identity-stable (hook cache /
- * host store cache / binding cache), so spreading a fresh kit object per
- * render never churns child subscriptions.
+ * useSessions hook, the session pair, the store pair when declared, the
+ * renderSlot binding when children are declared, and the SessionProvider
+ * seat when the children declare a session-scope slot. Hosts hand out BARE
+ * observable sources (hooks never cross the host contract); every hook is
+ * bound HERE, cached per source (observableHook), so spreading a fresh kit
+ * object per render never churns child subscriptions.
  */
 function standardKit(host: SlotRendererHost, entry: StoredEntry, cell: SessionCell | undefined): {
   kit: InjectedProps; actions: object | undefined
 } {
   const kit: InjectedProps = { useSessions: observableHook(host.sessions.list) }
   if (cell !== undefined) {
-    kit['useSession'] = cell.useSession
+    kit['useSession'] = observableHook(cell.session)
     kit['sessionId'] = cell.sessionId
   }
   const store = host.storeOf(entry, cell?.sessionId)
   if (store !== undefined) {
-    kit['useStore'] = store.useSelector
+    // The instance IS an observable snapshot source (contract getSnapshot/
+    // subscribe); the useStore hook binds here, cached per instance.
+    kit['useStore'] = observableHook(store)
     kit['actions'] = store.actions
   }
   if (entry.children !== undefined) {
     kit['renderSlot'] = boundRenderSlot(host, entry)
+    // SessionProvider standard seat: entries declaring a session-scope child
+    // render the session area, so the framework hands them the self-wired
+    // provider (module-level component = stable reference; no value import).
+    if (Object.values(entry.children).some((spec) => spec.scope === 'session')) {
+      kit['SessionProvider'] = SessionProvider
+    }
   }
   return { kit, actions: store?.actions }
 }

+ 3 - 2
packages/client/web-react/src/session-provider.tsx

@@ -7,9 +7,10 @@
  * index; business components see zero React contexts.
  */
 import { createContext, useContext, type ReactNode } from 'react'
-import type { HostObservable, SessionCell, SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
+import type {
+  HostObservable, SessionCell, SlotRendererHost, SnapshotSelectorHook,
+} from '@deepseek-ai/dsh-client-ui-slots'
 import { bindSnapshotSelector } from './bind.ts'
-import type { SnapshotSelectorHook } from './store/index.ts'
 
 /**
  * A missing-provider assembly error: the shell wired the tree wrong. The slot

+ 10 - 3
packages/client/web-react/tests/bind.spec.tsx

@@ -2,8 +2,15 @@
 import { StrictMode } from 'react'
 import { describe, expect, it, vi } from 'vitest'
 import { act, render } from '@testing-library/react'
-import { bindSnapshotSelector, shallowEqual } from '@deepseek-ai/dsh-client-web-react'
-import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react/store'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
+import type { HostObservable as ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
+
+// Local one-level equality: the engine's shallowEqual moved to runtime with
+// the store relocation, and web-react tests must not import runtime (the
+// dependency direction is runtime → web-react). The eq PARAMETER contract is
+// what this suite asserts, not any specific equality implementation.
+const shallowEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean =>
+  Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => Object.is(a[k], b[k]))
 
 interface Snap { a: number; b: number }
 
@@ -34,7 +41,7 @@ function Harness<S>({ useSelector, sel, eq, probe }: {
   useSelector: SnapshotSelectorHook<Snap>
   sel: (s: Snap) => S
   eq?: (a: S, b: S) => boolean
-  probe: { renders: number; value?: S }
+  probe: { renders: number; value?: S | undefined }
 }) {
   probe.renders += 1
   probe.value = useSelector(sel, eq)

+ 90 - 16
packages/client/web-react/tests/scoped-slots.spec.tsx

@@ -11,9 +11,9 @@
 import { describe, expect, it, vi } from 'vitest'
 import { act, render } from '@testing-library/react'
 import type { ReactNode } from 'react'
-import type { SlotEntryDef, SlotSpec, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
 import {
-  createSlotRenderer, defineStore, SessionProvider, SlotOwnershipError,
+  createSlotRenderer, SessionProvider, SlotOwnershipError,
   type RenderOpts, type SessionCell,
   type SlotRendererHost, type StoreInstanceLike,
 } from '@deepseek-ai/dsh-client-web-react'
@@ -25,6 +25,36 @@ type DeclaredSpec = SlotSpec<SlotEntryDef>
 const entryOf = (partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }): StoredEntry =>
   ({ options: {}, ...partial })
 
+/**
+ * Minimal store handle satisfying the StoreDecl contract shape (spec +
+ * create(scopeKey?) + instance with clearPersisted): the machinery consumes
+ * only the StoreInstanceLike face (bare snapshot source + baked actions),
+ * but entry.store is typed to the full contract — the real defineStore lives
+ * in runtime, which web-react tests must not import (dependency direction).
+ */
+function miniStore<T extends object>(init: () => T, mutators: Record<string, (state: T, ...params: never[]) => T>): StoreHandle<T, ActionsDecl<T>> {
+  return {
+    spec: { init, actions: {} },
+    create: () => {
+      let state = init()
+      const listeners = new Set<() => void>()
+      const actions: Record<string, (...params: never[]) => void> = {}
+      for (const key of Object.keys(mutators)) {
+        actions[key] = (...params: never[]) => {
+          state = mutators[key]!(state, ...params)
+          for (const fn of [...listeners]) fn()
+        }
+      }
+      return {
+        getSnapshot: () => state,
+        subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
+        actions,
+        clearPersisted: () => {},
+      } as StoreInstanceLike as ReturnType<StoreHandle<T, ActionsDecl<T>>['create']>
+    },
+  }
+}
+
 function observable<T>(initial: T) {
   let value = initial
   const subs = new Set<() => void>()
@@ -109,7 +139,11 @@ function makeHost() {
       }
     },
     addSession: (id: string): SessionCell => {
-      const cell: SessionCell = { sessionId: id, useSession: { hookTag: id } }
+      // Bare source per cell (identity-stable): the machinery binds useSession from it.
+      const cell: SessionCell = {
+        sessionId: id,
+        session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} },
+      }
       cells.set(id, cell)
       return cell
     },
@@ -242,12 +276,17 @@ describe('standard-kit synthesis', () => {
     expect(view.container.textContent).toBe('2')
   })
 
-  it('delivers the session pair (useSession identity + sessionId) under SessionProvider', () => {
+  it('delivers the session pair (bound useSession + sessionId) under SessionProvider', () => {
     const h = makeHost()
     h.declare('k.session', SINGLE_SESSION)
-    const cell = h.addSession('s1')
+    h.addSession('s1')
     const seen: AnyProps[] = []
-    h.add('k.session', { component: (props: object) => { seen.push(props as AnyProps); return null } })
+    h.add('k.session', {
+      component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
+        seen.push({ ...props, read: props.useSession!((s) => s.sid) })
+        return null
+      },
+    })
     mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => (
       <SessionProvider empty={() => <i>empty</i>}>
         {() => renderSlot('k.session', {})}
@@ -255,10 +294,51 @@ describe('standard-kit synthesis', () => {
     ))
     act(() => { h.current.set('s1') })
     const props = seen.at(-1)!
-    expect(props['useSession']).toBe(cell.useSession)
+    // The hook is BOUND by the machinery from the cell's bare source: it
+    // reads the source's snapshot and stays identity-stable across renders
+    // (per-source cache), which the switch-back cache tests cover.
+    expect(props['read']).toBe('s1')
     expect(props['sessionId']).toBe('s1')
   })
 
+  it('hands the SessionProvider seat to entries declaring a session-scope child', () => {
+    const h = makeHost()
+    h.declare('k.session', SINGLE_SESSION)
+    h.declare('k.single', SINGLE_ROOT)
+    h.addSession('s1')
+    h.add('k.session', { component: ({ sessionId }: { sessionId?: string }) => <b>{sessionId}</b> })
+    const rootSeen: AnyProps[] = []
+    // Root entry uses its INJECTED provider seat (no value import of SessionProvider).
+    h.add('root', {
+      component: (props: AnyProps) => {
+        rootSeen.push(props)
+        const Provider = props['SessionProvider'] as typeof SessionProvider
+        const renderSlot = props['renderSlot'] as RenderSlotFn
+        return (
+          <Provider empty={() => <i>empty</i>}>
+            {() => renderSlot('k.session', {})}
+          </Provider>
+        )
+      },
+      children: { 'k.session': SINGLE_SESSION },
+    })
+    const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
+    expect(view.container.textContent).toBe('empty')
+    act(() => { h.current.set('s1') })
+    expect(view.container.textContent).toBe('s1')
+
+    // Entries whose children are all root-scope get no provider seat.
+    const h2 = makeHost()
+    h2.declare('k.single', SINGLE_ROOT)
+    const seen2: AnyProps[] = []
+    h2.add('root', {
+      component: (props: AnyProps) => { seen2.push(props); return null },
+      children: { 'k.single': SINGLE_ROOT },
+    })
+    render(<>{createSlotRenderer().renderRoot(h2.host, {})}</>)
+    expect(seen2.at(-1)!['SessionProvider']).toBeUndefined()
+  })
+
   it('fails loud when a session slot renders outside SessionProvider', () => {
     const h = makeHost()
     h.declare('k.session', SINGLE_SESSION)
@@ -272,10 +352,7 @@ describe('standard-kit synthesis', () => {
   it('delivers the store pair for store-declaring entries and writes through baked actions', () => {
     const h = makeHost()
     h.declare('k.single', SINGLE_ROOT)
-    const handle = defineStore({
-      init: () => ({ n: 0 }),
-      actions: { inc: (d) => { d.n += 1 } },
-    })
+    const handle = miniStore(() => ({ n: 0 }), { inc: (s) => ({ n: s.n + 1 }) })
     let bump = () => {}
     h.add('k.single', {
       component: ({ useStore, actions }: {
@@ -298,10 +375,7 @@ describe('standard-kit synthesis', () => {
     h.declare('k.session', SINGLE_SESSION)
     h.addSession('s1')
     h.addSession('s2')
-    const handle = defineStore({
-      init: () => ({ draft: '' }),
-      actions: { setDraft: (d, text: string) => { d.draft = text } },
-    })
+    const handle = miniStore(() => ({ draft: '' }), { setDraft: (_s, text: string) => ({ draft: text }) })
     let setDraft: (text: string) => void = () => {}
     h.add('k.session', {
       component: ({ useStore, actions }: {
@@ -369,7 +443,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
     h.declare('k.single', SINGLE_ROOT)
     h.declare('k.session', SINGLE_SESSION)
     h.addSession('s1')
-    const handle = defineStore({ init: () => ({ n: 0 }), actions: { inc: (d) => { d.n += 1 } } })
+    const handle = miniStore(() => ({ n: 0 }), { inc: (s) => ({ n: s.n + 1 }) })
     const rootInject = vi.fn((actions: { inc: () => void }) => ({ viaRoot: actions }))
     const sessionInject = vi.fn((sessionId: string, actions: { inc: () => void }) => ({ sid: sessionId, viaSession: actions }))
     const seenRoot: AnyProps[] = []

+ 18 - 6
packages/client/web-react/tests/session-provider.spec.tsx

@@ -57,7 +57,11 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
     host,
     current,
     addSession: (id: string) => {
-      const cell: SessionCell = { sessionId: id, useSession: { hookTag: id } }
+      // Bare source per cell (identity-stable): the machinery binds useSession from it.
+      const cell: SessionCell = {
+        sessionId: id,
+        session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} },
+      }
       cells.set(id, cell)
       return cell
     },
@@ -121,15 +125,23 @@ describe('SessionProvider', () => {
     const h = makeHost({
       root: (renderSlot) => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
     })
-    const s1 = h.addSession('s1')
-    const s2 = h.addSession('s2')
-    h.registerSession({ component: (props: object) => { seen.push(props as Record<string, unknown>); return null }, options: {} })
+    h.addSession('s1')
+    h.addSession('s2')
+    h.registerSession({
+      component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
+        // The bound hook reads the cell's bare source — asserting through it
+        // proves the machinery wired THIS session's source, not another's.
+        seen.push({ sessionId: props.sessionId, read: props.useSession!((s) => s.sid) })
+        return null
+      },
+      options: {},
+    })
     render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
     act(() => { h.current.set('s1') })
-    expect(seen.at(-1)!['useSession']).toBe(s1.useSession)
+    expect(seen.at(-1)!['read']).toBe('s1')
     expect(seen.at(-1)!['sessionId']).toBe('s1')
     act(() => { h.current.set('s2') })
-    expect(seen.at(-1)!['useSession']).toBe(s2.useSession)
+    expect(seen.at(-1)!['read']).toBe('s2')
     expect(seen.at(-1)!['sessionId']).toBe('s2')
   })
 

+ 6 - 16
packages/client/web-react/tsdown.config.ts

@@ -1,12 +1,12 @@
 import { defineConfig } from 'tsdown'
 
 /**
- * Root shape plus the store subpath, built as SEPARATE single-entry bundles:
- * a multi-entry build emits a hash-named shared chunk that the exact `files`
- * whitelist cannot publish (same shape as code-runtime-worker/user-approval).
- * Each entry inlines the shared store code instead; the node lib is the
- * repo-uniform shape (publint/NodeNext), not an identity-sensitive runtime —
- * browser consumers resolve this package through the loader module table.
+ * Root and invariant shapes as SEPARATE single-entry bundles: a multi-entry
+ * build emits a hash-named shared chunk that the exact `files` whitelist
+ * cannot publish (same shape as code-runtime-worker/user-approval). The node
+ * lib is the repo-uniform shape (publint/NodeNext), not an identity-sensitive
+ * runtime — browser consumers resolve this package through the loader module
+ * table.
  */
 export default defineConfig([
   {
@@ -29,14 +29,4 @@ export default defineConfig([
     dts: false,
     clean: false,
   },
-  {
-    entry: { 'store/index': 'lib/types/store/index.js' },
-    outDir: 'lib',
-    format: ['esm'],
-    platform: 'neutral',
-    target: 'es2024',
-    fixedExtension: false,
-    dts: false,
-    clean: false,
-  },
 ])

+ 0 - 1
packages/client/web/package.json

@@ -30,7 +30,6 @@
     "react-dom": "^18.2.0"
   },
   "devDependencies": {
-    "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@types/react": "~18.3.1",
     "@types/react-dom": "~18.3.0",

+ 1 - 1
packages/client/web/src/AppRoot.tsx

@@ -7,7 +7,7 @@
  */
 import { useSyncExternalStore } from 'react'
 import type { ReactNode } from 'react'
-import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
+import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import type { LoaderStatus } from '@deepseek-ai/dsh-client-runtime/client'
 import css from './AppRoot.module.css'
 

+ 1 - 1
packages/client/web/src/boot.tsx

@@ -10,7 +10,7 @@
 import { Context } from 'cordis'
 import { createRoot } from 'react-dom/client'
 import type { ReactNode } from 'react'
-import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-web-react'
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
 import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
 import { createClientLoader, type ClientLoaderOptions } from '@deepseek-ai/dsh-client-runtime/loader'
 import { AppRoot } from './AppRoot.tsx'

+ 0 - 2
packages/client/web/src/seed.ts

@@ -13,7 +13,6 @@ import * as ReactDomClient from 'react-dom/client'
 import * as Cordis from 'cordis'
 import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots'
 import * as WebReact from '@deepseek-ai/dsh-client-web-react'
-import * as WebReactStore from '@deepseek-ai/dsh-client-web-react/store'
 import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
 
 /**
@@ -29,7 +28,6 @@ export function seedModules(): Record<string, unknown> {
     'cordis': Cordis,
     '@deepseek-ai/dsh-client-ui-slots': UiSlots,
     '@deepseek-ai/dsh-client-web-react': WebReact,
-    '@deepseek-ai/dsh-client-web-react/store': WebReactStore,
     '@deepseek-ai/dsh-client-ui-primitives': UiPrimitives,
   }
 }

+ 3 - 4
packages/client/web/tests/app-root.spec.tsx

@@ -9,10 +9,9 @@ import { afterEach, describe, expect, it } from 'vitest'
 import { act, cleanup, render } from '@testing-library/react'
 
 afterEach(cleanup)
-// createSnapshotStore left the public face (framework-internal engine); the
-// status-store stub reaches it through the same ./store channel runtime uses.
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
-import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-web-react'
+// The snapshot-store engine lives with runtime now; the status-store stub
+// uses the same channel production code does.
+import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
 import type { LoaderStatus } from '@deepseek-ai/dsh-client-runtime/client'
 import { AppRoot } from '@deepseek-ai/dsh-client-web/src/AppRoot.tsx'
 

+ 8 - 5
packages/client/web/tests/boot.spec.tsx

@@ -13,12 +13,13 @@
 import { afterEach, describe, expect, it } from 'vitest'
 import { act } from '@testing-library/react'
 import { bootWebShell } from '@deepseek-ai/dsh-client-web'
-import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
+import { createSnapshotStore, defineStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
 
 interface BootWindow extends Window {
   __DSH_BOOT__?: { plugins: { id: string; url: string; inject: string[]; immediately?: boolean }[] }
   DSHClientProxy?: unknown
   __TEST_SLOTS_SERVICE__?: unknown
+  __TEST_RUNTIME_STORE__?: { createSnapshotStore: unknown; defineStore: unknown }
 }
 const win = window as unknown as BootWindow
 
@@ -34,14 +35,14 @@ window.DSHClientProxy.loadPlugin({
   id: 'fake-runtime',
   factory: (require) => {
     const SlotsService = window.__TEST_SLOTS_SERVICE__
-    const { createSnapshotStore } = require('@deepseek-ai/dsh-client-web-react/store')
+    const { createSnapshotStore } = window.__TEST_RUNTIME_STORE__
     return {
       apply: (ctx) => {
         ctx.plugin(SlotsService)
         const list = createSnapshotStore({ ids: ['s1'], byId: { s1: { id: 's1', title: 'S1', running: false, updatedAt: 1 } }, current: 's1' })
         ctx.provide('sessions', {
           list,
-          cell: (id) => (id === 's1' ? { sessionId: 's1', useSelector: (sel) => sel({}) } : undefined),
+          cell: (id) => (id === 's1' ? { sessionId: 's1', session: { getSnapshot: () => ({}), subscribe: () => () => {} } } : undefined),
         })
       },
     }
@@ -55,7 +56,7 @@ window.DSHClientProxy.loadPlugin({
   id: 'fake-layout',
   factory: (require) => {
     const React = require('react')
-    const { defineStore } = require('@deepseek-ai/dsh-client-web-react')
+    const { defineStore } = window.__TEST_RUNTIME_STORE__
     return {
       inject: ['slots'],
       apply: (ctx) => {
@@ -130,13 +131,15 @@ afterEach(() => {
   delete win.__DSH_BOOT__
   delete win.DSHClientProxy
   delete win.__TEST_SLOTS_SERVICE__
+  delete win.__TEST_RUNTIME_STORE__
   document.body.innerHTML = ''
   document.head.querySelectorAll('script').forEach((s) => { s.remove() })
 })
 
-/** Hand the real service class to the stub bundle (runtime is not a seeded library). */
+/** Hand the real runtime surface to the stub bundle (runtime is not a seeded library). */
 function seedSlotsService(): void {
   win.__TEST_SLOTS_SERVICE__ = SlotsService
+  win.__TEST_RUNTIME_STORE__ = { createSnapshotStore, defineStore }
 }
 
 describe('bootWebShell (real loader + real script execution)', () => {

+ 8 - 26
pnpm-lock.yaml

@@ -508,9 +508,9 @@ importers:
 
   packages/client/i18n:
     dependencies:
-      '@deepseek-ai/dsh-client-web-react':
+      '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
-        version: link:../web-react
+        version: link:../runtime
     devDependencies:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
@@ -527,18 +527,21 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
-      '@deepseek-ai/dsh-client-web-react':
-        specifier: workspace:^
-        version: link:../web-react
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      immer:
+        specifier: ^10.1.1
+        version: 10.2.0
       react:
         specifier: ^18.2.0
         version: 18.3.1
+      zustand:
+        specifier: ~4.4.7
+        version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1)
     devDependencies:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
@@ -567,9 +570,6 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
-      '@deepseek-ai/dsh-client-web-react':
-        specifier: workspace:^
-        version: link:../web-react
       clsx:
         specifier: ^2.0.0
         version: 2.1.1
@@ -595,9 +595,6 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
-      '@deepseek-ai/dsh-client-web-react':
-        specifier: workspace:^
-        version: link:../web-react
       react:
         specifier: ^18.2.0
         version: 18.3.1
@@ -645,9 +642,6 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
-      '@deepseek-ai/dsh-client-web-react':
-        specifier: workspace:^
-        version: link:../web-react
       clsx:
         specifier: ^2.0.0
         version: 2.1.1
@@ -691,9 +685,6 @@ importers:
       '@deepseek-ai/dsh-client-ui-conversation':
         specifier: workspace:^
         version: link:../ui-conversation
-      '@deepseek-ai/dsh-client-web-react':
-        specifier: workspace:^
-        version: link:../web-react
       react:
         specifier: ^18.2.0
         version: 18.3.1
@@ -738,9 +729,6 @@ importers:
         specifier: ^18.2.0
         version: 18.3.1(react@18.3.1)
     devDependencies:
-      '@deepseek-ai/dsh-client-ui-layout':
-        specifier: workspace:^
-        version: link:../ui-layout
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../support/invariants
@@ -762,18 +750,12 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
-      immer:
-        specifier: ^10.1.1
-        version: 10.2.0
       react:
         specifier: ^18.2.0
         version: 18.3.1
       use-sync-external-store:
         specifier: 1.2.0
         version: 1.2.0(react@18.3.1)
-      zustand:
-        specifier: ~4.4.7
-        version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1)
     devDependencies:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^

+ 1 - 1
tsconfig.base.json

@@ -97,9 +97,9 @@
       "@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"],
       "@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"],
       "@deepseek-ai/dsh-client-web-react": ["./packages/client/web-react/src"],
-      "@deepseek-ai/dsh-client-web-react/store": ["./packages/client/web-react/src/store"],
       "@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"],
       "@deepseek-ai/dsh-client-runtime": ["./packages/client/runtime/src"],
+      "@deepseek-ai/dsh-client-runtime/client": ["./packages/client/runtime/src/client"],
       "@deepseek-ai/dsh-client-runtime/loader": ["./packages/client/runtime/src/client/loader"],
       "@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"],
       "@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"],