From 8b3d1ac943ab761fe1cecc72a46c3c300b58d9cf Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 02:56:16 +0800 Subject: [PATCH] refactor(gui): move the snapshot-store engine into the client runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...7-19-gui-web-client-architecture.i18n.yaml | 4 +- .../2026-07-19-gui-web-client-architecture.md | 2 +- ...26-07-19-gui-web-client-architecture.zh.md | 2 +- ...2-slot-type-chain-implementation.i18n.yaml | 4 +- ...26-07-22-slot-type-chain-implementation.md | 4 +- ...07-22-slot-type-chain-implementation.zh.md | 4 +- knip.json | 13 ++- packages/client/AGENTS.md | 4 +- packages/client/i18n/package.json | 2 +- packages/client/i18n/src/client/index.ts | 12 +- packages/client/i18n/tsconfig.json | 2 +- packages/client/runtime/package.json | 5 +- packages/client/runtime/src/client/index.ts | 11 +- .../client/runtime/src/client/loader/index.ts | 2 +- .../runtime/src/client/sessions/service.ts | 12 +- .../runtime/src/client/sessions/session.ts | 13 ++- .../src => runtime/src/client}/store/index.ts | 28 ++--- .../{web-react => runtime}/src/env.d.ts | 0 .../runtime/tests/sessions-service.spec.ts | 6 +- .../runtime/tests/slots-service.spec.ts | 14 ++- .../tests/store.spec.ts | 2 +- packages/client/tsdown.client.ts | 16 ++- packages/client/ui-conversation/package.json | 1 - .../src/client/chat/ChatView.tsx | 4 +- .../src/client/chat/StatsLine.tsx | 2 +- .../src/client/contract/toolview.ts | 2 +- .../src/client/contract/views.ts | 2 +- .../src/client/skeleton/ConversationRoot.tsx | 2 +- .../src/client/skeleton/DetailsPanel.tsx | 2 +- .../ui-conversation/src/client/stores.ts | 2 +- .../tests/apply-inject.spec.tsx | 4 +- .../ui-conversation/tests/chat-apply.spec.tsx | 2 +- .../tests/chat-branch-tails.spec.tsx | 6 +- .../tests/chat-stats-bash-sample.spec.tsx | 6 +- .../tests/chat-tool-row.spec.tsx | 2 +- .../ui-conversation/tests/chat-view.spec.tsx | 8 +- .../tests/coverage-tails.spec.tsx | 2 +- .../tests/gate-branch-tails.spec.tsx | 30 ++--- packages/client/ui-conversation/tests/hook.ts | 26 +++++ .../tests/selection-survival.spec.ts | 4 +- .../tests/skeleton-branches.spec.tsx | 24 ++-- .../ui-conversation/tests/skeleton.spec.tsx | 14 +-- packages/client/ui-layout/package.json | 1 - .../client/ui-layout/src/client/AppFrame.tsx | 15 +-- .../client/ui-layout/src/client/stores.ts | 2 +- .../client/ui-layout/tests/app-frame.spec.tsx | 42 +++---- packages/client/ui-sidebar/package.json | 1 - .../client/ui-sidebar/tests/apply.spec.tsx | 2 +- .../ui-sidebar/tests/sidebar-root.spec.tsx | 16 ++- packages/client/ui-slots/README.md | 2 +- packages/client/ui-slots/src/index.ts | 35 +++++- packages/client/ui-slots/src/renderer.ts | 24 +++- packages/client/ui-slots/src/store.ts | 20 ++-- .../client/ui-slots/tests/type-chain.spec.tsx | 6 + packages/client/ui-trajectory/package.json | 1 - .../src/client/TrajectoryStatsHeader.tsx | 2 +- .../src/client/TrajectoryView.tsx | 2 +- .../src/client/WaterfallView.tsx | 2 +- .../client/ui-trajectory/tests/views.spec.tsx | 12 +- packages/client/web-react/README.md | 2 +- packages/client/web-react/package.json | 11 +- packages/client/web-react/src/bind.ts | 14 ++- packages/client/web-react/src/index.ts | 25 ++--- .../client/web-react/src/scoped-slots.tsx | 24 ++-- .../client/web-react/src/session-provider.tsx | 5 +- packages/client/web-react/tests/bind.spec.tsx | 13 ++- .../web-react/tests/scoped-slots.spec.tsx | 106 +++++++++++++++--- .../web-react/tests/session-provider.spec.tsx | 24 +++- packages/client/web-react/tsdown.config.ts | 22 +--- packages/client/web/package.json | 1 - packages/client/web/src/AppRoot.tsx | 2 +- packages/client/web/src/boot.tsx | 2 +- packages/client/web/src/seed.ts | 2 - packages/client/web/tests/app-root.spec.tsx | 7 +- packages/client/web/tests/boot.spec.tsx | 13 ++- pnpm-lock.yaml | 34 ++---- tsconfig.base.json | 2 +- 77 files changed, 484 insertions(+), 317 deletions(-) rename packages/client/{web-react/src => runtime/src/client}/store/index.ts (90%) rename packages/client/{web-react => runtime}/src/env.d.ts (100%) rename packages/client/{web-react => runtime}/tests/store.spec.ts (99%) create mode 100644 packages/client/ui-conversation/tests/hook.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 7fba74af19..b3b5462ecf 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 32ec0a371f..f704a00c3b 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.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` (`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` (`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. diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index 8a929a3e25..3aa8fdbe82 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.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`(`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`(`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` 浅比较。深比较全链禁止。 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml index b1b67af99e..9e671e3672 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md index f6c5e09d53..1e9bd711e8 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md @@ -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` 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). diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md index 61ad6e8604..0eab839d03 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md @@ -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`——账本、坑、贡献、渲染绑定、store 实例全部沿同一条 entry 轴生灭,跨插件重载的陈旧权威窗口由此在构造上关闭(已 dispose 的 entry 所捕获的 `renderSlot`,一进入口即抛陈旧授权(stale-authorization)错误)。 diff --git a/knip.json b/knip.json index dbc1e585de..a2d2dc91b5 100644 --- a/knip.json +++ b/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": { diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 08290791f8..d3011ed85f 100644 --- a/packages/client/AGENTS.md +++ b/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: diff --git a/packages/client/i18n/package.json b/packages/client/i18n/package.json index 1e3d81b275..5b5d4e718c 100644 --- a/packages/client/i18n/package.json +++ b/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", diff --git a/packages/client/i18n/src/client/index.ts b/packages/client/i18n/src/client/index.ts index 8fa5715047..37e1c0cdb5 100644 --- a/packages/client/i18n/src/client/index.ts +++ b/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' diff --git a/packages/client/i18n/tsconfig.json b/packages/client/i18n/tsconfig.json index dd593f678d..76dd83e8d6 100644 --- a/packages/client/i18n/tsconfig.json +++ b/packages/client/i18n/tsconfig.json @@ -18,7 +18,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../web-react" + "path": "../runtime" }, { "path": "../../support/invariants" diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index d17ff8e4b4..384b2e2e3c 100644 --- a/packages/client/runtime/package.json +++ b/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", diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 8c669a20bc..a403484280 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/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 +export type UseConversationSession = SnapshotSelectorHook /** * One tool call as the chat flow renders it: still-running (spinner card) or diff --git a/packages/client/runtime/src/client/loader/index.ts b/packages/client/runtime/src/client/loader/index.ts index f8166add9c..1afa4b5eb5 100644 --- a/packages/client/runtime/src/client/loader/index.ts +++ b/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' diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 430ca81478..eb4983c839 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/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 diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 0934118bd1..7330738de8 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/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 { - /** Typed selector hook bound to this instance (the SessionBinding `useSession` source). */ - readonly useSelector: SnapshotSelectorHook = 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). diff --git a/packages/client/web-react/src/store/index.ts b/packages/client/runtime/src/client/store/index.ts similarity index 90% rename from packages/client/web-react/src/store/index.ts rename to packages/client/runtime/src/client/store/index.ts index a4291fc171..ce4444cf36 100644 --- a/packages/client/web-react/src/store/index.ts +++ b/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 { 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 extends ObservableSnapshot { /** * Mutate the state through an immer draft. @@ -37,14 +38,11 @@ export interface SnapshotStore extends ObservableSnapshot { * @param next - next state. */ set(next: T): void - readonly useSelector: SnapshotSelectorHook } -/** Typed selector hook: equality defaults to Object.is; pass shallowEqual for object slices. */ -export type SnapshotSelectorHook = (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( } } - const store: SnapshotStore = { + return { getSnapshot: () => api.getState(), subscribe: fn => subscribe(fn), update: (mutator) => { @@ -115,10 +113,7 @@ export function createSnapshotStore( set: (next) => { api.setState(devFreeze(next), true) }, - useSelector: undefined as unknown as SnapshotSelectorHook, } - ;(store as { useSelector: SnapshotSelectorHook }).useSelector = bindSnapshotSelector(store) - return store } /** @@ -230,7 +225,6 @@ export function defineStore>( actions[key] = (...params: unknown[]) => { store.update((draft) => { mutate(draft, ...params) }) } } return { - useSelector: store.useSelector, actions: actions as BakedActions, getSnapshot: () => store.getSnapshot(), subscribe: fn => store.subscribe(fn), diff --git a/packages/client/web-react/src/env.d.ts b/packages/client/runtime/src/env.d.ts similarity index 100% rename from packages/client/web-react/src/env.d.ts rename to packages/client/runtime/src/env.d.ts diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 7e0546335b..9d18069887 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/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() }) diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index 88d73d4ac0..12f4f1f05f 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -49,9 +49,10 @@ async function boot(): Promise { 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 clearPersisted: ReturnType } @@ -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), } } diff --git a/packages/client/web-react/tests/store.spec.ts b/packages/client/runtime/tests/store.spec.ts similarity index 99% rename from packages/client/web-react/tests/store.spec.ts rename to packages/client/runtime/tests/store.spec.ts index 2855028748..22de90c0cf 100644 --- a/packages/client/web-react/tests/store.spec.ts +++ b/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 } diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 860c9cc908..94d050765d 100644 --- a/packages/client/tsdown.client.ts +++ b/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 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index e9a3aed8ce..51bdffb2d6 100644 --- a/packages/client/ui-conversation/package.json +++ b/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" }, diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 24b2e278fe..b444e557ec 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/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 diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 78f036aecf..ebaa485117 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/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' diff --git a/packages/client/ui-conversation/src/client/contract/toolview.ts b/packages/client/ui-conversation/src/client/contract/toolview.ts index e2f652e19a..f79478a57e 100644 --- a/packages/client/ui-conversation/src/client/contract/toolview.ts +++ b/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' diff --git a/packages/client/ui-conversation/src/client/contract/views.ts b/packages/client/ui-conversation/src/client/contract/views.ts index 0d91e74855..e201b67e20 100644 --- a/packages/client/ui-conversation/src/client/contract/views.ts +++ b/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' /** diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index cf0aeec947..de970381ff 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/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' diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index ed018de252..54cb0bfb97 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/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' diff --git a/packages/client/ui-conversation/src/client/stores.ts b/packages/client/ui-conversation/src/client/stores.ts index e1d70ea3ed..4a3d27cdbc 100644 --- a/packages/client/ui-conversation/src/client/stores.ts +++ b/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' /** diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 3b01a71996..4631e08d77 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/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, diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 78ab4947b1..68734b9d10 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/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' diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index bac6af5747..aad467f9f5 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/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( - , + , ) expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy() }) diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 3da6d82e28..9b6d6e94a7 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/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', () => { diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index d1858787a5..1a381adda8 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/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' diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 96dbca34f9..3bbef02fe8 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/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) { 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) } diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 2bdcb52902..ee69156729 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/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' diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 3ee795344e..a9010828c4 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/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( - } />, + } />, ) expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy() }) @@ -90,9 +76,9 @@ describe('render branch tails', () => { const view = render( snap, subscribe: () => () => {} }) as unknown as UseSession} - useSessions={emptyList.useSelector} - useStore={chat.useSelector} + useSession={hookOf({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession} + useSessions={hookOf(emptyList)} + useStore={hookOf(chat)} actions={chat.actions} closeDetails={vi.fn()} />, diff --git a/packages/client/ui-conversation/tests/hook.ts b/packages/client/ui-conversation/tests/hook.ts new file mode 100644 index 0000000000..b26c9c8430 --- /dev/null +++ b/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 { + 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(src: HookSource) { + return bindSnapshotSelector(src) +} diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.ts b/packages/client/ui-conversation/tests/selection-survival.spec.ts index be2cc63f56..a637b4fa18 100644 --- a/packages/client/ui-conversation/tests/selection-survival.spec.ts +++ b/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) => { diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index 0ff0098c9d..52d89d2b19 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/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( } + useSession={hookOf(sessionSource(over?.snapshot)) as unknown as UseSession} 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( } + useSession={hookOf(sessionSource()) as unknown as UseSession} 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( } + useSession={hookOf(sessionSource(snapshot)) as unknown as UseSession} useSessions={listHook([])} - useStore={chat.useSelector} + useStore={hookOf(chat)} actions={chat.actions} closeDetails={vi.fn()} />, @@ -183,9 +183,9 @@ describe('DetailsPanel branches', () => { const view = render( } + useSession={hookOf(source) as unknown as UseSession} useSessions={listHook([])} - useStore={chat.useSelector} + useStore={hookOf(chat)} actions={chat.actions} closeDetails={vi.fn()} />, diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 4b9b034353..75172dd207 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/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 = {}) { const store = createSnapshotStore({ nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init, }) - return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession } + return { store, useSession: hookOf(store) as unknown as UseSession } } /** 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} />) diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index f9c556c26a..3448e62f5c 100644 --- a/packages/client/ui-layout/package.json +++ b/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": { diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index 1a1ba96746..5c5e989957 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/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(null) const [viewport, setViewport] = useState(() => window.innerWidth) diff --git a/packages/client/ui-layout/src/client/stores.ts b/packages/client/ui-layout/src/client/stores.ts index 8e3f429d39..206f2874bb 100644 --- a/packages/client/ui-layout/src/client/stores.ts +++ b/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, diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index 0f848f93f1..7e65445a3a 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/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[0])} : <>{empty?.() ?? null} -vi.mock('@deepseek-ai/dsh-client-web-react', async (importOriginal) => { - const mod = await importOriginal() - 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(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) { + return (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( , ) 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)', () => { diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 91432fb79a..b188841b43 100644 --- a/packages/client/ui-sidebar/package.json +++ b/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" }, diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index c301029769..31c98b1843 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/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' diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 39e746e37c..6beb07bdc4 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/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(src: { getSnapshot(): T; subscribe(fn: () => void): () => void }) { + return (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[]) { ` 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` 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. diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index cbca60beb8..fbce352db9 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/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 = /** 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`). + */ +export type UseSession = SnapshotSelectorHook + +/** 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 = { */ renderSlot: (key: K, owner: OwnerOf, opts?: RenderOpts) => ReactNode readonly __renders?: ((key: S) => void) | undefined -} +} & ('session' extends ScopeOf + // 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 diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index 9e4f84d647..1605199c2a 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -16,19 +16,33 @@ export interface HostObservable { /** * 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 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 } /** renderSlot dispatch options at the machinery level: keyed dispatch key, list filtering, empty fallback. */ diff --git a/packages/client/ui-slots/src/store.ts b/packages/client/ui-slots/src/store.ts index eca515daef..e1c66c73a9 100644 --- a/packages/client/ui-slots/src/store.ts +++ b/packages/client/ui-slots/src/store.ts @@ -51,19 +51,19 @@ export interface StoreSpec> { /** * 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> { - /** Selector hook bound to this instance (delivered to components as `useStore`). */ - readonly useSelector: SnapshotSelectorHook /** Baked write callbacks (delivered to components as `actions`). */ readonly actions: BakedActions - /** 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 extends StoreHandle : 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 = >(spec: StoreSpec) => StoreHandle diff --git a/packages/client/ui-slots/tests/type-chain.spec.tsx b/packages/client/ui-slots/tests/type-chain.spec.tsx index b5aaaf9f52..d45dfee0a3 100644 --- a/packages/client/ui-slots/tests/type-chain.spec.tsx +++ b/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') }) diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 0721ab99f0..d6f93e8156 100644 --- a/packages/client/ui-trajectory/package.json +++ b/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": { diff --git a/packages/client/ui-trajectory/src/client/TrajectoryStatsHeader.tsx b/packages/client/ui-trajectory/src/client/TrajectoryStatsHeader.tsx index 4fb143dce9..f250452f2e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryStatsHeader.tsx +++ b/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' diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index a1cda2441d..4f4dbb1939 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/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' diff --git a/packages/client/ui-trajectory/src/client/WaterfallView.tsx b/packages/client/ui-trajectory/src/client/WaterfallView.tsx index c9549dbc73..a2e555d963 100644 --- a/packages/client/ui-trajectory/src/client/WaterfallView.tsx +++ b/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' diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 06f6e4e860..e7f99d9128 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/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( { 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} useSessions={emptySessions()} - useStore={chat.useSelector} + useStore={bindSnapshotSelector(chat)} actions={chat.actions} views={{ list: () => svc.views(), diff --git a/packages/client/web-react/README.md b/packages/client/web-react/README.md index d9c9bd7e9f..fcc67a8ec2 100644 --- a/packages/client/web-react/README.md +++ b/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 diff --git a/packages/client/web-react/package.json b/packages/client/web-react/package.json index 60e48421a5..2d5c08b53a 100644 --- a/packages/client/web-react/package.json +++ b/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" diff --git a/packages/client/web-react/src/bind.ts b/packages/client/web-react/src/bind.ts index 8814c09365..7d72eced6c 100644 --- a/packages/client/web-react/src/bind.ts +++ b/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(w: ObservableSnapshot): SnapshotSelectorHook { +export function bindSnapshotSelector(w: HostObservable): SnapshotSelectorHook { const subscribe = (fn: () => void) => w.subscribe(fn) const getSnapshot = () => w.getSnapshot() return function useSelector(sel: (s: T) => S, eq?: (a: S, b: S) => boolean): S { diff --git a/packages/client/web-react/src/index.ts b/packages/client/web-react/src/index.ts index 3c39b18bc2..cd3189b891 100644 --- a/packages/client/web-react/src/index.ts +++ b/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 = SnapshotSelectorHook @@ -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 } } diff --git a/packages/client/web-react/src/session-provider.tsx b/packages/client/web-react/src/session-provider.tsx index 277954229a..8d9058c132 100644 --- a/packages/client/web-react/src/session-provider.tsx +++ b/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 diff --git a/packages/client/web-react/tests/bind.spec.tsx b/packages/client/web-react/tests/bind.spec.tsx index e60b7d1098..d872664add 100644 --- a/packages/client/web-react/tests/bind.spec.tsx +++ b/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, b: Record): 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({ useSelector, sel, eq, probe }: { useSelector: SnapshotSelectorHook 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) diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx index f296828a78..8cbbf21be4 100644 --- a/packages/client/web-react/tests/scoped-slots.spec.tsx +++ b/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 const entryOf = (partial: Omit & { 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(init: () => T, mutators: Record T>): StoreHandle> { + return { + spec: { init, actions: {} }, + create: () => { + let state = init() + const listeners = new Set<() => void>() + const actions: Record 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>['create']> + }, + } +} + function observable(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?: (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) => ( empty}> {() => 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 }) => {sessionId} }) + 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 ( + empty}> + {() => renderSlot('k.session', {})} + + ) + }, + 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[] = [] diff --git a/packages/client/web-react/tests/session-provider.spec.tsx b/packages/client/web-react/tests/session-provider.spec.tsx index 3dcca24fa3..f4654b4ccd 100644 --- a/packages/client/web-react/tests/session-provider.spec.tsx +++ b/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) => {() => renderSlot('k.session', {})}, }) - const s1 = h.addSession('s1') - const s2 = h.addSession('s2') - h.registerSession({ component: (props: object) => { seen.push(props as Record); return null }, options: {} }) + h.addSession('s1') + h.addSession('s2') + h.registerSession({ + component: (props: { useSession?: (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') }) diff --git a/packages/client/web-react/tsdown.config.ts b/packages/client/web-react/tsdown.config.ts index 51bd9b8660..65378be678 100644 --- a/packages/client/web-react/tsdown.config.ts +++ b/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, - }, ]) diff --git a/packages/client/web/package.json b/packages/client/web/package.json index 70826127c1..6c3e89080c 100644 --- a/packages/client/web/package.json +++ b/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", diff --git a/packages/client/web/src/AppRoot.tsx b/packages/client/web/src/AppRoot.tsx index d8b7e0d49d..401d253b78 100644 --- a/packages/client/web/src/AppRoot.tsx +++ b/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' diff --git a/packages/client/web/src/boot.tsx b/packages/client/web/src/boot.tsx index 4e586d12ce..0e9c68be9e 100644 --- a/packages/client/web/src/boot.tsx +++ b/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' diff --git a/packages/client/web/src/seed.ts b/packages/client/web/src/seed.ts index d4bfcd81e9..90f7f547ef 100644 --- a/packages/client/web/src/seed.ts +++ b/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 { '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, } } diff --git a/packages/client/web/tests/app-root.spec.tsx b/packages/client/web/tests/app-root.spec.tsx index 280c4f8c89..1aa0639908 100644 --- a/packages/client/web/tests/app-root.spec.tsx +++ b/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' diff --git a/packages/client/web/tests/boot.spec.tsx b/packages/client/web/tests/boot.spec.tsx index 35404b57ec..46866cfef5 100644 --- a/packages/client/web/tests/boot.spec.tsx +++ b/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)', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 968891f28d..d9b18ff2e5 100644 --- a/pnpm-lock.yaml +++ b/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:^ diff --git a/tsconfig.base.json b/tsconfig.base.json index e3ebddc46b..28f7e2d0f3 100644 --- a/tsconfig.base.json +++ b/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"],