refactor(gui): slot system standard — single register, four props shares, framework store seat

The definitive slot model for the web client, replacing the first-generation
define/register two-step, ScopedSlots whitelist faces, and binding handles:

- 'root' is the only a-priori slot (SlotsService built-in); the shell renders
  exactly ctx.slots.renderSlot('root', {}).
- register is the single API: children = slot declaration + render
  authorization + runtime spec in one options object; misconfiguration fails
  loud at load (duplicate declaration, undeclared contribution, one store
  handle under two scopes).
- Component props arrive in four auto-derived shares: PropsRuntime<K>
  (owner params + session/global standard kits via declare-merge),
  PropsRenderSlots<S>, PropsStore<H>, and the inject business face.
  sessionId is framework-supplied; hooks are framework-made only.
- Framework store seat: defineStore factories declare schema/actions/persist;
  read = useStore, write = baked actions only; store scope derives from the
  mounting entry; per-session persist keys and clearPersisted lifecycle.
- inject factories read the apply closure's own ctx (binding handles retired;
  root-ctx back door closed); SessionProvider is self-wired render-prop.
- Rendering sits behind the SlotRenderer install seam; runtime stays
  React-free; ownership ledger keyed to the single entry axis closes the
  stale-authority window (StaleAuthorizationError probes).

Docs: the slot type-chain note is refreshed in place as the slot system
standard RFC (bilingual pair re-recorded); the web client architecture RFC
defers its slot sections there; packages/client/AGENTS.md gains the slot and
props discipline; gui-testing/web-styling notes drop missions/ references.

Tests: suites rewritten to the standard (props fed directly, real store
engines via createXXXStore().create(), no render machinery); load-time
negative samples for declaration/authorization/store conflicts; verified by
real-host playwright run (three columns, empty state, collapse, keyed session
remount, cross-slot selection sharing).

docs(ui-sidebar): point contract reference at the committed slot standard RFC

missions/ is workspace-local and never committed; the README must not cite it.
This commit is contained in:
imccyu
2026-07-23 03:25:11 +08:00
parent efa4326ff4
commit 1b0ea07bce
95 changed files with 5024 additions and 3322 deletions
@@ -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: 58320570f752d4259004172d3b4527172c2cc646
2026-07-19-gui-web-client-architecture.zh.md: 744fdaa4b89a01e2710f85b177228713189e3025
2026-07-19-gui-web-client-architecture.md: 32ec0a371f78aff8841949b983ddcca475ed6831
2026-07-19-gui-web-client-architecture.zh.md: 8a929a3e253d26b7394ad374dc0844b03fcd8b5c
@@ -43,28 +43,13 @@ Dev equals prod: plugins rebuild under `tsdown --watch`, refresh reloads the sam
## The slot system: how the page composes
A page is a tree of slots; whoever owns a region declares its slots. Contracts live in one place — the `SlotMap` interface in `@deepseek-ai/dsh-client-ui-slots`, extended by declaration merging. An entry declares the slot's axes and the **owner share** only; the registrant's injected props never enter the global table ("whoever injects it, owns its type"):
The slot system has its own RFC — the [slot system standard](2026-07-22-slot-type-chain-implementation.md) — and this document defers to it entirely. The one-paragraph summary for orientation: the shell renders only `'root'`; a plugin composes UI through a single `register` call that occupies a slot, declares+authorizes its child slots (`children` spec object), declares its store, and injects its business face; component props arrive in four auto-derived shares (`PropsRuntime<K>` / `PropsRenderSlots<S>` / `PropsStore<H>` / inject), each from its single source of truth. `SlotMap` declaration merging is the type authority and entries carry only the owner share ("whoever injects it, owns its type"); every rendered entry sits in a per-entry error boundary.
```ts ignore-check
declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap {
sidebar: { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps }
conversation: { kind: 'single'; scope: 'session'; owner: ConvOwnerProps; children: 'conversation.empty' }
} }
ctx.slots.define('sidebar', { kind: 'single', scope: 'root' }) // declare=类型,define=落账
ctx.slots.register('sidebar', SidebarRoot, { inject: (b) => ({ /* ... */ }) })
```
- Three kinds: `single` (duplicate registration throws), `list` (id/order), `keyed` (runtime dispatch, duplicate key throws). Register before define throws. Two scopes: `root` (no session context) and `session` — the scope decides the injection shape below.
- **Full component props are composed by reference, never re-typed**: a registrant's component declares `OwnerOf<K> & StandardOf<K> & OwnInjected` — the owner share referenced from the slot owner's package, the standard share supplied by the framework (session slots: `useSession`), and the registrant's own injected share declared locally next to the component. `register<K, I>` enforces the composition at the call site: the component parameter is `SlotComponent<ComposedProps<K, NoInfer<I>>>` (a bare call signature, not `FC` — FC's `propTypes` static position generates contravariance noise against the standard share), and `I` is inferred exclusively from the inject factory's return type (`NoInfer` pins it), so a drifted component or a mismatched factory is a compile error at the registration point. In ui-conversation the injected shares live in `src/client/contract/slots.ts` (`ConversationInjected` and kin) and each skeleton component's props is a one-line reference composition.
- **Delegation is a hand-written whitelist with an optional declared ceiling**: an owner component receives a whitelist-narrowed `slots: ScopedSlots<'a' | 'b'>` through its own props and calls `slots.renderSlot(key, props)`; passing a narrowed subset to a child goes through `narrowSlots` (pure type covariance). Overreach is a compile error, and the runtime whitelist backstops plain-JS callers. An entry may additionally declare `children: <key>` — register then validates the component's whitelist ⊆ the declared ceiling (opt-in visibility layer, not mandatory). Every rendered entry is wrapped in a per-entry error boundary: a crashing registrant (component or inject factory) blacks out only its own entry, while assembly errors (missing providers) rethrow — a miswired shell fails loud instead of degrading.
- **Props merge from three sources** (the outlet does it; owners write only the first): ① owner-supplied props (identity, display parameters, frozen slices) — typed as the entry's owner share, exact at the renderSlot point; ② scope-standard injection — session slots automatically receive `useSession` bound to the right Session; ③ the registrant's `inject` factory, called once per (entry × session) for session slots and once per entry for root slots, cached in WeakMaps so a session switch-back reuses the cached result. Inject factories receive the assembly handle (`SessionBinding { sessionId, session, ctx }` or `RootBinding { ctx }`) — an apply-world object that never enters React.
- Two supply channels close the loop: `RootBindingProvider` (mounted once by the shell) feeds root-slot inject factories their ctx; `createSessionProvider(deps)` builds the single session provider — dependency-inverted (`useCurrent` / `resolveBinding` / `renderBody`), so web-react never imports the runtime. It subscribes to the current session id, resolves a reference-stable binding, remounts its body under `key={id}`, and delegates body rendering to the assembler's `renderBody` closure (slot ownership stays with layout; the provider knows no slot names).
Implementation homes: registry core in `packages/client/ui-slots` (zero dependencies), outlet/providers/uSES bridge in `packages/client/web-react`.
Implementation homes: registry core and the props-share types in `packages/client/ui-slots`, outlet/renderer/uSES bridge in `packages/client/web-react`.
## Services and scope addressing
A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-map merges). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`), `ctx.sessions` (list store, scope tree, bindings), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (navigation + panel viewing state), `ctx.conversation` (send/cancel/selection/views/startSession), `ctx.toolviews` (named per-tool render registry with per-session scope filters).
A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-map merges). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/views/startSession), `ctx.toolviews` (named per-tool render registry with per-session scope filters). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md).
Beyond SlotMap, two more typed registration rings follow the same declare-merge idiom: the **view ring** (`ConversationViewMap` — an entry may declare `chromeProps`/`extraProps` extension shapes; `ConvViewPropsOf<Id>`/`ChromePropsOf<Id>` compose base + extension, so a view with no declaration gets the base for free while ui-trajectory's entries carry real per-view props) and the **tool ring** (tool names stay an open set — no global key table; typing hardens inside the entry: `ToolViewProps.block` is the real `ToolCallBlock` union defined in runtime, and register infers the registrant's injected share like slots do).
@@ -101,7 +86,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──
The glue package is the whole ctx↔React boundary; components stay framework-free.
- `createSnapshotStore<T>(init, opts)`: the store engine for plugin-owned data and shell viewing state — 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. Both a Session object and a snapshot store satisfy the one data contract React consumes: `ObservableSnapshot<T>` (`getSnapshot`/`subscribe`).
- The snapshot store engine: zustand vanilla with draft-based updates, `flush: 'sync'` by default (controlled inputs need same-tick echo) with opt-in `'raf'` batching for frame-driven stores, opt-in whole-value localStorage persistence, dev-mode deep freeze. Plugins reach it only through `defineStore` declarations per the [slot system standard](2026-07-22-slot-type-chain-implementation.md); the framework (runtime data layer, renderer machinery) is the engine's only direct consumer. Both a Session object and a snapshot store satisfy the one data contract React consumes: `ObservableSnapshot<T>` (`getSnapshot`/`subscribe`).
- `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.
@@ -128,9 +113,9 @@ Domain implementation files never import a sibling domain — shared surfaces ro
## How to develop
- **A new UI feature** = a new plugin package: declare `dshClient` (+ `inject` topology) in package.json, write the browser half under `src/client/` (apply mounts services/stores, registers slots and toolviews), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically.
- **A new slot**: merge the contract into `SlotMap`, `define` at the owner, render through the owner's own `ScopedSlots` whitelist; registrants `register` with an optional inject factory. Never export components globally.
- **A new slot**: see the [slot system standard RFC](2026-07-22-slot-type-chain-implementation.md) — merge the contract into `SlotMap`, declare it in the parent entry's `children`, render through the auto-injected `renderSlot` prop. Never export components globally.
- **Consuming a new frame type**: sessionId-bearing → a branch in Session's dispatch switch; host-level → the Manager routing table; if the UI needs it, a `ConversationSnapshot` field with the reference discipline kept.
- **Where does this state live**: per-session and must survive switches → the Session object / scope-mounted store; private to one view (selection, scroll) → component state; shell viewing state (navigation, panel widths, preferences) → `ctx.layout`'s stores; business data → always the object layer, never a viewing-state store.
- **Where does this state live**: business data (events, streaming, pending) → always the object layer; what the parent knows → owner props at the renderSlot site; private to one component (scroll, search text, expansion) → component state; shared across entries or surviving remounts (selection, drafts, panel widths) → an entry-declared store ([slot system standard](2026-07-22-slot-type-chain-implementation.md)).
- **Notification channel**: frame-driven/async = `markDirty` batching; direct user-gesture echo whose controlled input needs the same tick = `notifyNow`.
## Consequences
@@ -43,28 +43,13 @@ dev 与 prod 同链:插件在 `tsdown --watch` 下重编译,刷新即重走
## slot 体系:页面怎么拼
页面是一棵坑位树;谁拥有区域谁声明坑位。契约只有一个家——`@deepseek-ai/dsh-client-ui-slots` `SlotMap` 接口,经声明合并扩展。entry 只声明坑的轴与 **owner 份额**;注册方的注入 props 永不进全局表(「谁注入的放谁那里」):
slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain-implementation.md)——本文整体移交给它。此处只留一段定位摘要:壳只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——占坑、声明并授权子坑(`children` spec 对象)、声明 store、注入业务面;组件 props 分四份额自动推导到达(`PropsRuntime<K>` / `PropsRenderSlots<S>` / `PropsStore<H>` / inject),各有唯一真源。`SlotMap` 声明合并仍是类型权威,entry 只携带 owner 份额(「谁注入的,类型归谁」);每个被渲染的注册项都在 per-entry 错误边界之内。
```ts ignore-check
declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap {
sidebar: { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps }
conversation: { kind: 'single'; scope: 'session'; owner: ConvOwnerProps; children: 'conversation.empty' }
} }
ctx.slots.define('sidebar', { kind: 'single', scope: 'root' }) // declare=类型,define=落账
ctx.slots.register('sidebar', SidebarRoot, { inject: (b) => ({ /* ... */ }) })
```
- 三型:`single`(重复注册即 throw)、`list`id/order)、`keyed`(运行时按 key 分发,重 key 即 throw)。define 之前 register 即 throw。两 scope`root`(无会话语境)与 `session`——scope 决定下述注入形态。
- **组件全量 props 一律引用组合,不重抄**:注册方组件声明 `OwnerOf<K> & StandardOf<K> & OwnInjected`——owner 份额从坑位 owner 的包引用、标配份额由框架供给(session 坑:`useSession`)、注册方自己的注入份额就地声明在组件旁。`register<K, I>` 在调用点强制组合:组件形参位是 `SlotComponent<ComposedProps<K, NoInfer<I>>>`(裸调用签名而非 `FC`——FC 的 `propTypes` 静态位对标配份额产生反变噪音),`I` 只从 inject 工厂返回值推断(`NoInfer` 钉死),组件漂移或工厂不匹配都在注册点编译报错。ui-conversation 的注入份额住 `src/client/contract/slots.ts``ConversationInjected` 族),各骨架组件的 props 是一行引用组合。
- **转授=手写白名单+可选声明上限**:owner 组件经自己的 props 拿到白名单收窄的 `slots: ScopedSlots<'a' | 'b'>`,调 `slots.renderSlot(key, props)` 渲染;把收窄子集递给子组件走 `narrowSlots`(纯类型协变)。越权是编译错误,运行时白名单再兜住纯 JS 调用方。entry 可另声明 `children: <key>`——register 校验组件白名单 ⊆ 声明上限(可选可见层,不强制)。每个被渲染的注册项都包在 per-entry 错误边界里:注册方崩溃(组件或 inject 工厂)只黑自己那一格,装配错误(缺 provider)则重抛——接错线的壳大声失败而不是静默降级。
- **props 三源合并**(出口组件来做;owner 只写第一份):① owner 供参(身份、展示参数、冻结切片)——按 entry 的 owner 份额强类型,renderSlot 点即精确;② scope 标配注入——session 坑自动获得绑定正确 Session 的 `useSession`;③ 注册方的 `inject` 工厂,session 坑 per-(注册项 × 会话) 调一次、root 坑 per-注册项调一次,以 WeakMap 缓存——切回会话时复用缓存结果。inject 工厂收到装配句柄(`SessionBinding { sessionId, session, ctx }` 或 `RootBinding { ctx }`)——apply 世界的对象,永不进入 React。
- 两条供给通道收拢闭环:`RootBindingProvider`(壳顶部挂一次)为 root 坑 inject 工厂供给 ctx`createSessionProvider(deps)` 构造唯一的会话 provider——依赖倒置(`useCurrent` / `resolveBinding` / `renderBody`),web-react 永不 import runtime。它订阅当前会话 id、解析引用恒等的 binding、以 `key={id}` 重挂其 body,并把 body 渲染委托给装配方的 `renderBody` 闭包(坑位所有权留在 layout;provider 不认识坑名)。
实现的家:注册表纯核在 `packages/client/ui-slots`(零依赖),出口组件/provider/uSES 桥在 `packages/client/web-react`。
实现的家:注册表核心与 props 份额类型在 `packages/client/ui-slots`,出口组件/渲染器/uSES 桥在 `packages/client/web-react`
## 服务与 scope 寻址
服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只 merge 视图表)。名册:`ctx.connection`api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`)、`ctx.sessions`(列表 store、scope 树、binding)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(导航 + 面板观看态)、`ctx.conversation`send/cancel/selection/views/startSession)、`ctx.toolviews`(具名按工具渲染注册表,带按会话 scope 过滤)。
服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只 merge 视图表)。名册:`ctx.connection`api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader``ctx.theme``ctx.i18n``ctx.layout`(跨插件视图导航)、`ctx.conversation`send/cancel/views/startSession)、`ctx.toolviews`(具名按工具渲染注册表,带按会话 scope 过滤)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。
SlotMap 之外还有两条同 declare-merge 惯例的类型化注册环:**视图环**(`ConversationViewMap`——entry 可声明 `chromeProps`/`extraProps` 扩展形状;`ConvViewPropsOf<Id>`/`ChromePropsOf<Id>` 组合基座+扩展,无声明的视图免费得基座,ui-trajectory 的两个 entry 带真 per-view props)与**工具环**(tool 名保持开放集——无全局键表;类型强化在 entry 内部:`ToolViewProps.block` 是 runtime 定义的真 `ToolCallBlock` unionregister 同 slots 一样推断注册方注入份额)。
@@ -101,7 +86,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──
胶水包就是整条 ctx↔React 边界;组件保持零框架依赖。
- `createSnapshotStore<T>(init, opts)`:插件自有数据与壳观看态的 store 引擎——zustand vanilla + 草稿式更新,缺省 `flush: 'sync'`(受控输入要求同 tick 回响),帧驱动 store 可选 `'raf'` 合批,可选整值 localStorage 持久化,dev 深冻结。Session 对象与快照 store 同构满足 React 消费的唯一数据契约:`ObservableSnapshot<T>``getSnapshot`/`subscribe`)。
- 快照 store 引擎zustand vanilla + 草稿式更新,缺省 `flush: 'sync'`(受控输入要求同 tick 回响),帧驱动 store 可选 `'raf'` 合批,可选整值 localStorage 持久化,dev 深冻结。插件只经 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 的 `defineStore` 声明触及它;引擎的直接消费方只有框架(runtime 数据层、渲染器机械)。Session 对象与快照 store 同构满足 React 消费的唯一数据契约:`ObservableSnapshot<T>``getSnapshot`/`subscribe`)。
- `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` 浅比较。深比较全链禁止。
@@ -128,9 +113,9 @@ src/client/
## 怎么开发
- **新 UI 功能** = 新插件包:package.json 声明 `dshClient`+ `inject` 拓扑),浏览器半边写在 `src/client/`apply 挂服务/建 store、注册 slot 与 toolview),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;清单与装载随之自动跟上。
- **新 slot**:契约合并进 `SlotMap`owner 处 `define`,经 owner 自己的 `ScopedSlots` 白名单渲染;注册方 `register`,按需带 inject 工厂。永不全局导出组件。
- **新 slot**见 [slot 体系标准 RFC](2026-07-22-slot-type-chain-implementation.md)——契约合并进 `SlotMap`,在父 entry 的 `children` 里声明,经自动注入的 `renderSlot` prop 渲染。永不全局导出组件。
- **消费新帧类型**:带 sessionId → Session 分发 switch 加一个分支;host 级 → Manager 路由表;UI 需要时给 `ConversationSnapshot` 加字段并守住引用纪律。
- **状态住哪**per-session 且要跨切换存续 → Session 对象 / scope 挂账 store;单视图私有(选中、滚动)→ 组件状态;壳观看态(导航、面板宽、偏好)→ `ctx.layout` 的 store;业务数据 → 永远对象层,永不进观看态 store
- **状态住哪**业务数据(事件、流式、待答)→ 永远对象层;父知道的 → renderSlot 现场的 owner props;单组件私有(滚动、搜索词、展开集)→ 组件状态;跨 entry 共享或跨重挂载存活(选中、草稿、面板宽)→ entry 声明的 store[slot 体系标准](2026-07-22-slot-type-chain-implementation.md)
- **通知通道**:帧驱动/异步 = `markDirty` 合批;受控输入需要同 tick 的用户手势直接回响 = `notifyNow`
## Consequences
@@ -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: b4ec761b9777f5dfbd59efde8c472f9be4c2e1b6
2026-07-22-slot-type-chain-implementation.zh.md: 28b6e4a3db0c87322582125825492703e62371b2
2026-07-22-slot-type-chain-implementation.md: f6c5e09d53e442c4f10f30ad03d7d50729ebba0f
2026-07-22-slot-type-chain-implementation.zh.md: 61ad6e86042bb0899604461b976ab55da037a765
@@ -1,47 +1,109 @@
# Agent Note: Slot type-chain hardening — the non-obvious implementation rulings
# Agent Note: The slot system standard — single register, four props shares, and the framework store seat
Status: implemented
English | [中文](2026-07-22-slot-type-chain-implementation.zh.md)
> Scope: why the slot registration/render type chain (`packages/client/ui-slots/src/index.ts`, consumed by `packages/client/web-react/src/scoped-slots.tsx`) is implemented the way it is. The design-level trade-offs (registration-site inference over declaration tables, hand-written whitelists over derived ones) live in the web client architecture RFC; this note pins the five implementation decisions a future editor would otherwise re-litigate or accidentally revert.
> Scope: the definitive slot-system design for the web client — how UI plugins compose the page, where render authority lives, how component props are typed, and where business live-data goes. The [web client architecture RFC](2026-07-19-gui-web-client-architecture.md) owns the surrounding context (loading chain, object layer, services) and defers its slot sections here.
## Problem
The hardened chain types every hop from `SlotMap` declaration to rendered component: owner share + framework-standard share + registrant-injected share compose into the component's props, checked at `register()`. Making that constraint hold without false rejections forced five choices that look arbitrary from the code alone — each one exists because the obvious alternative fails in a specific, reproducible way.
The page is composed at runtime from independently loaded plugins, so the UI needs a composition mechanism that answers four questions with static force. Who may render into a region — and is that authority enforceable, or merely conventional? How does a component receive everything it needs while staying a pure function (no ctx, no framework imports), without every value being hand-threaded through assembly code? Where does business live-data live so that streaming updates re-render precisely the subscribers — without every plugin building its own subscription machinery? And how much of this can the compiler check, so that a drifted component, an over-reaching render call, or a mismatched store schema is a compile error at one visible call site rather than a runtime surprise?
## Decision
### 1. `SlotComponent<P>` (bare call signature) instead of `FC<P>` at the registration position
One sentence: **the shell renders only `'root'`; a plugin composes UI through a single `register` call that simultaneously occupies a slot, declares+authorizes its child slots, declares its store, and injects its business face; components are pure functions whose props arrive in four shares, each auto-derived from its single source of truth.**
`register()` constrains components as `SlotComponent<ComposedProps<K, NoInfer<I>>>` where `SlotComponent<P> = (props: P) => ReactNode`. React's `FC` carries static fields (`propTypes`, `defaultProps`) whose types reference `P` in covariant positions; assignability between two `FC` instantiations therefore checks those statics too, and the bottom-typed standard share (see ruling 4's `useSession: never`) makes those covariant checks reject components that narrow it — precisely the components the design wants to accept. The bare call signature checks through clean parameter contravariance only. Components stay ordinary functions; nothing observable changes at runtime.
### 'root' is the only a-priori slot
### 2. `NoInfer<I>` pins the registrant share's inference to the inject factory
`SlotsService` (client runtime) declares `'root'` at construction — single/root, `owner: {}` — and its `SlotMap` merge lives in the runtime package. The shell's entire assembly is `ctx.slots.renderSlot('root', {})`: the only ctx-level render entry; any other key, a missing renderer, or an unregistered root fails loud (no fallback).
`I` (the registrant's injected share) must be inferred from the `inject` factory's return type — the single authoritative source. Without `NoInfer`, TS also collects inference candidates from the component parameter position, and a drifted component (consuming a key the factory does not supply) silently WIDENS `I` to make the call check, absorbing the drift instead of reporting it. `NoInfer<I>` at the component position removes that candidate site, so negative sample ⑥ (a hand-drifted copy of the owner share fails at `register`) actually fails — with inference bleed it would pass. If the `NoInfer` ever gets "simplified away", the type-chain spec's expect-error site goes red first.
### register is the single API; children = declaration + authorization + runtime spec
### 3. `ComposedProps` dispatches on the entry's `owner` key for progressive migration
```ts ignore-check
ctx.slots.register({
name: 'root',
children: {
'sidebar': { kind: 'single', scope: 'root' },
'conversation': { kind: 'single', scope: 'session' },
},
store: createLayoutStore, // StoreHandle or factory (below)
inject: injectFrame, // business face (below)
}, AppFrame)
```
`ComposedProps<K, I>` composes `owner & standard & I` only when the SlotMap entry declares an `owner` share; entries without one fall back to the legacy full-`props` constraint (`PropsShape`). This conditional is the migration seam: legacy declarations keep compiling unchanged while entries opt into the composed model one at a time, and both forms flow through the same `register()` overload — no parallel API, no flag. Removing the fallback branch is the flip-the-switch moment for the whole repo, not a cleanup.
There is no separate slot-definition API. The `children` object both **declares the child slots into existence** and **authorizes this component to render them** — a slot is a hole in the render tree that exists because someone will render it, so its lifecycle is the declaring entry's lifecycle (entry disposed → slots gone, contributions cleared). The values are the runtime spec (`kind`/`scope` drive outlet iteration and binding selection; `SlotMap` is types-only and erased at runtime, which is why an array of keys could not work), statically checked against the `SlotMap` entry so type and value are declared at one point and cross-validated.
### 4. The standard share is bottom-typed, and bare `register` bivariance is accepted, not fought
Parity rule: **the declaring entry holds the exclusive right to render its child slots**, settled entirely at register time (misconfiguration fails loud at load; the render hot path carries no checks). Loud-at-load cases: a second entry declaring an already-declared slot; registering into an undeclared slot; one store handle mounted under two scopes.
Session slots' framework-supplied hook is constrained as `{ useSession: never }` (`StandardOf`): `never` in a parameter-ish position means any registrant narrowing (e.g. a runtime-typed conversation hook) is accepted, and the responsibility for what actually arrives lives with the injecting renderer. Known boundary rider: for components typed with METHOD syntax or otherwise bivariant parameter positions, TS can accept a `register` call it strictly shouldn't (parameter bivariance is unsound by design in TS). The accepted stance is documented rather than tested: we do not add negative samples that depend on strictness TS does not guarantee — they would pin compiler-version behavior, not our contract. The samples we do pin (six expect-error sites in `packages/client/ui-slots/tests/type-chain.spec.tsx`) all fail for contract reasons.
`SlotMap` declaration merging remains the type authority, and an entry declares only its own axes plus the **owner share** — the registrant's injected props never enter the global table ("whoever injects it, owns its type").
### 5. `ChildrenChecked` is an opt-in validation layer keyed on the entry's `children` declaration
### Component props: four shares, each from its own source of truth
Sub-slot delegation authority stays a hand-written whitelist (`slots: ScopedSlots<'a' | 'b'>` in the component's own props). `ChildrenChecked<K, P>` adds an optional second check: only when the entry declares `children` does the component's `slots` face get validated against the authorized union (violation collapses `slots` to `never`, surfacing at the register call). Entries without `children` pass through untouched. The hook point is inside `ComposedProps` — i.e. it fires exactly at the registration boundary, not at render — because register is where both halves (entry declaration, component face) are statically visible at once; a render-time check would need runtime plumbing for a purely static guarantee.
| Share | Type | Source of truth | Contents |
|---|---|---|---|
| runtime | `PropsRuntime<K>` | SlotMap entry for K | `OwnerOf<K>` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions` |
| child render | `PropsRenderSlots<S>` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S |
| store | `PropsStore<H>` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) |
| business | `I` | inject return type | plain data + callbacks (hooks banned) |
`sessionId` is framework-supplied wherever `scope: 'session'` is declared — owner params do not carry it. The register call site is the double-lock choke point: a component whose renderSlot keys exceed the `children` declaration, or that misses a declared face, or whose store/inject shapes drift, is a compile error on that line. Delegation is ordinary props passing (hand the `renderSlot` function down, optionally behind a narrower signature) — there is no whitelist face object and no minting API.
### 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):
```ts ignore-check
export function createChatStore() {
return defineStore({
init: () => ({ selection: null as SelectionTarget | null, draft: '' }),
persist: 'dsh.conversation.chat',
actions: {
select: (d, t: SelectionTarget) => { d.selection = t },
clearDraft:(d) => { d.draft = '' },
},
})
}
```
One factory, three consumption points: (a) `register` — pass the factory for an exclusive store, or call it once in `apply` and pass the same handle to several registers to share the instance (cross-plugin sharing is constructively impossible: the handle never leaves the package); (b) `PropsStore<ReturnType<typeof createChatStore>>` derives the component's store share with zero hand-written members; (c) tests call the factory and `.create()` a real engine instance, feeding `useSelector`/`actions` straight in as props — production outlets run the very same `create` path, so there is no second machinery.
Store scope is **derived from the mounting entry's scope** (session slot → one instance per session, living and dying with the session; root slot → one per entry). Read = `props.useStore`; write = `props.actions.*` only — the raw instance (with `update`/`set`) never reaches a component, so the declared actions are the complete, auditable mutation surface. Production code never calls the factory or `create` outside `apply`.
### inject: the registrant's business face, on its own ctx
An inject factory takes what its declarations earn it — `sessionId` for session slots, bound `actions` when a store is declared, nothing otherwise — and reads services through the **apply closure's own ctx**, so its capability boundary is the plugin's declared `inject` topology (the cordis property proxy applies natively; there is no assembly handle carrying a wider ctx). Its return value is plain data and callbacks only: the narrowed read/write face of the plugin's own services, cross-service orchestration (e.g. `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`), and per-(entry×session) assembly side effects. No hooks, no ReactNode producers, no whole-service objects — narrowing is the value: what a component can do is exactly the factory's return shape.
### Data-boundary discipline
Hooks are framework-made only: `useSession`, `useSessions`, `useStore`, `renderSlot` are the four seats, implemented once with framework-guaranteed correctness; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own.
### 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.
Rendering lives behind an install seam so the runtime stays React-free: `SlotRenderer` (interface in ui-slots, implementation `createSlotRenderer()` in web-react) is installed once at shell boot via `ctx.slots.install(...)`; double install and render-before-install throw. Ownership bookkeeping is a single `Map<key, entry>` in the service — ledger, slots, contributions, render bindings, and store instances all live and die on the one entry axis, which closes the stale-authority window across plugin reloads by construction (a disposed entry's captured `renderSlot` throws a stale-authorization error on entry).
### Type-chain implementation rulings
Two hardening decisions in the register signature exist because the obvious alternative fails in a specific, reproducible way; a future editor should not re-litigate them:
1. **`SlotComponent<P>` (bare call signature) instead of `FC<P>` at the registration position.** React's `FC` carries static fields (`propTypes`, `defaultProps`) whose types reference `P` in covariant positions; assignability between two `FC` instantiations checks those statics too and rejects components the design wants to accept. The bare call signature checks through clean parameter contravariance only; components stay ordinary functions.
2. **`NoInfer<I>` pins the business share's inference to the inject factory.** Without it, TS also collects inference candidates from the component parameter position, and a drifted component (consuming a key the factory does not supply) silently widens `I` to make the call check — absorbing exactly the drift the chain exists to catch. The negative-sample spec pins this: if the `NoInfer` is ever "simplified away", the expect-error site goes red first.
## Consequences
The register call site is now the chain's single choke point: share drift, missing inject keys, unauthorized sub-slot faces, and keyed/list option omissions all surface there at compile time, and the six-sample negative spec pins each failure mode. Costs: the conditional types make hover-signatures at register sites noticeably wider; the bottom-typed standard share shifts arrival-type responsibility onto web-react's renderer (documented on `StandardOf`); and the bivariance boundary means one unsound-accept class is knowingly tolerated.
Render authority is enforceable rather than conventional: who renders what is a load-time fact, and auditing the UI structure = reading the register calls. Every props surface is statically derived from one source (SlotMap entry, children keys, store factory, inject return), so a schema change propagates by compiler rather than by grep. Plugins carry no subscription machinery of their own — store lifecycle (per-session instances, disposal, persistence) is framework semantics keyed to the entry axis. Costs: registration options are dense (children spec objects); the framework carries real inference machinery (`defineStore`'s init/actions same-round inference may need a curried fallback); and the compile-time double locks mean prototype-stage drift is a hard error, not a warning.
## Alternatives considered
| Rejected | One-line reason |
|---|---|
| Keep `FC` and cast at register sites | The casts hide exactly the drift the chain exists to catch; FC statics' covariant noise is the mechanical cause, so remove the noise, not the check |
| Infer `I` from the component parameter | Inference bleed absorbs props drift silently — negative sample ⑥ becomes unwritable |
| Big-bang migration to composed props | Every SlotMap declarant lands in one PR; the `owner`-keyed conditional lets entries migrate one by one with both forms live |
| Test the bivariant-accept edge as a negative sample | Would pin TS soundness behavior we don't own; compiler upgrades would break the spec without any contract change |
| Derive delegation whitelists from `children` declarations | The hand-written face is the API the component author reads; derivation inverts ownership and was rejected at design level — `ChildrenChecked` validates instead of generating |
| Separate define/register two-step API | The split leaves render authority unenforced and invites ordering bugs; children-in-register settles declaration, authorization, and spec in one visible place |
| Whitelist face objects (`ScopedSlots` + narrowing helpers) | With the whitelist already in the component's props type, the face is derivable by machinery; a mintable face object is a third authority surface with runtime-only checks |
| Assembly handles carrying root ctx into inject | Bypasses declared inject topology — every factory could reach every service, so package.json dependency declarations stop meaning anything |
| `children` as a key array | kind/scope are runtime dispatch data; SlotMap is erased, so an array forces a second spec-registration API — a definition API reborn |
| Business-defined hooks via inject | Every plugin becomes its own subscription machine; the framework store seat carries the same data with one audited machine |
| Module-level store handles | A module-scope handle is a singleton across plugin reloads and test cases; the factory form scopes identity to apply/test invocation |
| Components receiving the store instance | `update`/`set` in render code makes the mutation surface unauditable; declared actions keep "what can change" a register-site fact |
| `FC` at the register position / inferring `I` from the component | FC statics generate covariant noise that rejects valid components; component-side inference absorbs props drift silently (see rulings above) |
@@ -1,47 +1,109 @@
# Agent Note: slot 类型链硬化——五条非显然实现裁定
# Agent Note: slot 体系标准——单一 register、props 四份额与框架 store 席位
Status: implemented
[English](2026-07-22-slot-type-chain-implementation.md) | 中文
> 范围:slot 注册/渲染类型链(`packages/client/ui-slots/src/index.ts`,消费方 `packages/client/web-react/src/scoped-slots.tsx`)为什么这样实现。设计层取舍(注册点推断优于声明表、手写白名单优于派生)住 Web 客户端架构 RFC;本文钉住五条实现决定——不写下来,将来的编辑者要么重新争论一遍,要么不经意地回退它们
> 范围:Web 客户端 slot 体系的终版设计——UI 插件如何拼合页面、渲染权威落在哪里、组件 props 如何定型、业务活数据住在哪里。周边语境(装载链、对象层、服务)归 [Web 客户端架构 RFC](2026-07-19-gui-web-client-architecture.md) 所有,其 slot 各节移交本文
## Problem
硬化后的类型链给从 `SlotMap` 声明到组件渲染的每一跳定型:owner 份额 + 框架标配份额 + 注册方注入份额组合成组件 props,在 `register()` 处校验。让这条约束既成立又不误伤,逼出了五个单看代码显得任意的选择——每一个的存在都是因为显然的替代方案会以一种具体的、可复现的方式失败。
页面在运行时由各自独立装载的插件拼合而成,UI 因此需要一套能以静态强制力回答四个问题的组合机制。谁可以渲染进某块区域——这份权威是可强制执行的,还是仅靠约定?组件如何在保持纯函数(零 ctx、零框架 import)的同时拿到它需要的一切,而不必把每个值都经装配代码手工穿线?业务活数据住在哪里,才能让流式更新恰好只重渲染订阅者——而不必每个插件自建一套订阅机械?以及这一切有多少能交给编译器检查,让漂移的组件、越权的渲染调用、错配的 store schema 成为单一可见调用点上的编译错误,而非运行时的意外?
## Decision
### 1. 注册位用 `SlotComponent<P>`(裸调用签名)而非 `FC<P>`
一句话:**壳只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——这一次调用同时占坑、声明并授权子坑、声明 store、注入业务面;组件是纯函数,props 分四份额到达,每一份额都从各自唯一的真源自动推导。**
`register()``SlotComponent<ComposedProps<K, NoInfer<I>>>` 约束组件,其中 `SlotComponent<P> = (props: P) => ReactNode`。React 的 `FC` 携带静态字段(`propTypes``defaultProps`),其类型在协变位引用 `P`;两个 `FC` 实例化之间的可赋性因此连这些静态位一起查,而 bottom 型的标配份额(见裁定 4 的 `useSession: never`)使这些协变检查拒绝掉收窄它的组件——恰恰是设计想接受的那批组件。裸调用签名只走干净的参数逆变检查。组件仍是普通函数;运行时零可见差异。
### 'root' 是唯一的先验坑
### 2. `NoInfer<I>` 把注册方份额的推断钉在 inject 工厂上
`SlotsService`client 运行时)在构造时声明 `'root'`——single/root、`owner: {}`——其 `SlotMap` 合并声明住 runtime 包(package)。壳的全部装配就是 `ctx.slots.renderSlot('root', {})`:唯一的 ctx 级渲染入口;传任何其他键、渲染器未安装、root 无人注册,一律大声失败(无 fallback)。
`I`(注册方注入份额)必须从 `inject` 工厂的返回类型推断——唯一权威源。没有 `NoInfer` 时,TS 还会从组件参数位收集推断候选,漂移的组件(消费一个工厂并不供给的键)会静默地把 `I` 加宽到让调用通过,把漂移吸收掉而不是报出来。组件位的 `NoInfer<I>` 移除了那个候选位,负样本⑥(owner 份额的手抄漂移件在 register 处失败)才得以成立——有推断渗漏时它会通过。将来若有人把这个 `NoInfer`「顺手简化」掉,类型链 spec 的 expect-error 位会第一个变红。
### register 是唯一 APIchildren = 声明+授权+运行时 spec
### 3. `ComposedProps` 按条目的 `owner` 键分派,支撑渐进迁移
```ts ignore-check
ctx.slots.register({
name: 'root',
children: {
'sidebar': { kind: 'single', scope: 'root' },
'conversation': { kind: 'single', scope: 'session' },
},
store: createLayoutStore, // StoreHandle or factory (below)
inject: injectFrame, // business face (below)
}, AppFrame)
```
`ComposedProps<K, I>` 只在 SlotMap 条目声明了 `owner` 份额时才组合 `owner & standard & I`;未声明的条目回落到 legacy 全量 `props` 约束(`PropsShape`)。这个条件类型就是迁移接缝:legacy 声明原样编译,条目逐个转入组合模型,两种形态走同一个 `register()`——无平行 API、无开关旗。删掉回落分支的那一刻=全仓切换时刻,不是一次清理
不存在独立的坑位定义 API。`children` 对象同时做两件事:**把子坑声明出来**,并**授权本组件渲染它们**——坑是渲染树上的一个洞,因为有人要渲染它才存在,所以坑的生命周期就是声明它的 entry 的生命周期(entry 一经 dispose(资源释放),坑随之消亡、坑内既有贡献清空)。children 的值是运行时 spec`kind`/`scope` 驱动 outlet 的迭代形态与 binding 选择;`SlotMap` 是纯类型、运行时即被擦除,这正是键数组形行不通的原因),并与对应 `SlotMap` entry 静态对齐校验——类型与值在同一点声明、交叉验证
### 4. 标配份额 bottom 型化;裸 `register` 的双变接受面认账不硬测
对等原则:**声明子坑的 entry 独占渲染这些子坑的权力**,全部在 register 时结清(配置错误在装载时大声失败;渲染热径零校验)。装载即炸的情形:第二个 entry 声明已被声明的坑;向未声明的坑 register;同一个 store 句柄挂到两个 scope 之下。
session 坑的框架供给 hook 约束为 `{ useSession: never }``StandardOf`):参数性位置上的 `never` 意味着任何注册方收窄(如 runtime 定型的会话 hook)都被接受,实际到达什么的类型责任归注入侧渲染器。已知边界搭车项:对以方法语法定型或参数位本就双变的组件,TS 可能接受一个严格意义上不该过的 `register` 调用(参数双变是 TS 的有意不健全)。这个立场以文档记账而不加测试:我们不写依赖 TS 并不承诺的严格性的负样本——那钉住的是编译器版本行为,不是我们的契约。真正钉住的六个 expect-error 位(`packages/client/ui-slots/tests/type-chain.spec.tsx`)全部因契约原因失败
`SlotMap` 声明合并仍是类型权威,且 entry 只声明自己的轴加 **owner 份额**——注册方注入的 props 永不进入全局表(「谁注入的,类型归谁」)
### 5. `ChildrenChecked` 是按条目 `children` 声明挂载的 opt-in 校验层
### 组件 props:四份额,各有唯一真源
子坑转授权威仍是手写白名单(组件自己 props 上的 `slots: ScopedSlots<'a' | 'b'>`)。`ChildrenChecked<K, P>` 加一层可选的第二道检查:仅当条目声明了 `children`,组件的 `slots` 面才对照授权并集校验(越界时 `slots` 坍缩为 `never`,在 register 调用处暴露)。未声明 `children` 的条目原样通过。挂点选在 `ComposedProps` 内部——即恰好在注册边界而非渲染期起效——因为 register 是条目声明与组件面两个半边同时静态可见的唯一位置;渲染期检查要为一个纯静态保证铺运行时管线。
| 份额 | 类型 | 真源 | 内容 |
|---|---|---|---|
| 运行时 | `PropsRuntime<K>` | K 对应的 SlotMap entry | `OwnerOf<K>`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions` |
| 子坑渲染 | `PropsRenderSlots<S>` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S |
| store | `PropsStore<H>` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) |
| 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) |
凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双向锁的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。
### store 席位:引擎归框架,schema 归注册方
框架拥有恰好一台订阅机械(uSES 快照 store 引擎——zustand vanilla + immer + 可选 localStorage 持久化)。store 里*装什么*是注册方的声明,且必须写成工厂函数,使模块级句柄根本无从存在(模块级句柄会成为跨插件重载存活的事实单例):
```ts ignore-check
export function createChatStore() {
return defineStore({
init: () => ({ selection: null as SelectionTarget | null, draft: '' }),
persist: 'dsh.conversation.chat',
actions: {
select: (d, t: SelectionTarget) => { d.selection = t },
clearDraft:(d) => { d.draft = '' },
},
})
}
```
一个工厂,三个消费点:① `register`——独占 store 直接传工厂;要共享实例,则在 `apply` 里调用一次工厂、把同一句柄传给多次 register(跨插件共享构造性不可能:句柄从不出包);② `PropsStore<ReturnType<typeof createChatStore>>` 推导出组件的 store 份额,零手写成员;③ 测试自己调用工厂并 `.create()` 出真引擎实例,把 `useSelector`/`actions` 直接当 props 喂进去——生产 outlet 走的正是同一条 `create` 路径,不存在第二套机械。
store 的 scope **从挂载 entry 的 scope 推导**session 坑→每个会话一个实例,随会话生灭;root 坑→每个 entry 一个)。读 = `props.useStore`;写 = 仅 `props.actions.*`——裸实例(带 `update`/`set`)永远到不了组件,声明的 actions 就是完整且可审计的变更面。生产代码在 `apply` 之外从不调用工厂或 `create`。
### inject:注册方的业务面,立足自己的 ctx
inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑(cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值只含普通数据与回调:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。
### 数据界线纪律
hook 只许框架造:`useSession`、`useSessions`、`useStore`、`renderSlot` 是仅有的四席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。
### 树上语境与渲染器安装缝
`SessionProvider` 是框架组件,框架自接线(内部自读 runtime 的当前会话状态,装配方零传参),render-prop 形——`children(sessionId)` 外加 `empty` 分支,以 `key={sessionId}` 重挂。`BindingContext` 属机械内部;业务组件可见的 React Context 为零。inject 工厂有意在 outlet 内部执行(per-entry 错误边界接得住它们;崩溃的注册方只黑掉自己那一格,装配错误则重抛);outlet 把树上语境当作仅机械可用的暗参读取——即「身份出自 register 闭包、现场出自树位置」的分工。
渲染住在一条安装缝之后,runtime 因此保持 React-free`SlotRenderer`(接口住 ui-slots,实现 `createSlotRenderer()` 住 web-react)在壳 boot 时经 `ctx.slots.install(...)` 安装一次;双重安装与安装前渲染均 throw。归属记账是服务里的单一 `Map<key, entry>`——账本、坑、贡献、渲染绑定、store 实例全部沿同一条 entry 轴生灭,跨插件重载的陈旧权威窗口由此在构造上关闭(已 dispose 的 entry 所捕获的 `renderSlot`,一进入口即抛陈旧授权(stale-authorization)错误)。
### 类型链实现裁定
register 签名里的两条硬化裁定之所以存在,是因为显然的替代方案会以具体、可复现的方式失败;将来的编辑者不应重新争论它们:
1. **注册位用 `SlotComponent<P>`(裸调用签名)而非 `FC<P>`。** React 的 `FC` 携带静态字段(`propTypes`、`defaultProps`),其类型在协变位引用 `P`;两个 `FC` 实例化之间的可赋性检查连这些静态位一起查,会拒绝设计本想接受的组件。裸调用签名只走干净的形参逆变检查;组件仍是普通函数。
2. **`NoInfer<I>` 把业务份额的推断钉在 inject 工厂上。** 没有它,TS 还会从组件形参位收集推断候选,漂移的组件(消费一个工厂并不供给的键)会静默把 `I` 加宽到让调用通过——恰好吸收掉类型链本要抓的漂移。负样本 spec 钉住这一点:若这个 `NoInfer` 日后被「顺手简化」掉,expect-error 位会第一个变红。
## Consequences
register 调用点成为全链唯一收口:份额漂移、inject 键缺失、越权子坑面、keyed/list options 缺省全部在编译期于此暴露,六样本负样本 spec 逐一钉住失败模式。代价:条件类型让 register 位的悬停签名明显变宽;bottom 型标配份额把到达类型的责任转给 web-react 渲染器(记录于 `StandardOf`);双变边界意味着一类不健全接受被知情容忍
渲染权威从此可强制执行,而非仅靠约定:谁渲染什么是装载期事实,审计 UI 结构 = 通读 register 调用。每个 props 面都从单一真源静态推导(SlotMap entry、children 键集、store 工厂、inject 返回值),schema 变更由编译器传播,而不靠 grep。插件不再自带任何订阅机械——store 生命周期(每会话实例、dispose、持久化)是钉在 entry 轴上的框架语义。代价:注册选项稠密(children spec 对象);框架背上实打实的推断机械(`defineStore` 的 init/actions 同轮推断可能需要柯里化兜底);编译期双向锁意味着原型阶段的漂移直接是硬错误,而非警告
## Alternatives considered
| Rejected | One-line reason |
|---|---|
| 保留 `FC`、在 register 位 cast | cast 恰好藏起类型链要抓的漂移;FC 静态位的协变噪音是机械成因,该移除噪音而非移除检查 |
| 从组件参数位推断 `I` | 推断渗漏静默吸收 props 漂移——负样本⑥无从写起 |
| 组合 props 一次性全仓迁移 | 所有 SlotMap 声明方挤进一个 PR;`owner` 键分派让条目逐个迁移、两形态共存 |
| 给双变接受边缘加负样本 | 钉住的是我们不拥有的 TS 健全性行为;编译器升级会在契约零变化时打红 spec |
| `children` 声明派生转授白名单 | 手写面才是组件作者读到的 API;派生反转所有权,设计层已否——`ChildrenChecked` 做校验不做生成 |
| 独立的 define/register 两步式 API | 拆分让渲染权威无从强制、招来时序 bugchildren 进 register 让声明、授权、spec 在同一个可见位置结清 |
| 白名单面对象(`ScopedSlots` + 收窄辅助件) | 白名单已在组件的 props 类型里,面可由机械推导;可铸造的面对象是第三个权威面,且只有运行时校验 |
| 装配句柄把 root ctx 带进 inject | 绕开声明的 inject 拓扑——每个工厂都摸得到每个服务,package.json 的依赖声明就此失去意义 |
| `children` 用键数组形 | kind/scope 是运行时分派数据;SlotMap 已被擦除,数组形必然逼出第二个 spec 注册 API——定义 API 复活 |
| 业务经 inject 自定义 hook | 每个插件都变成自己的订阅机械;框架 store 席位用一台受审计的机械承载同样的数据 |
| 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 |
| 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 |
| 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) |
@@ -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-web-styling-system.md: c80ef0d56a0e57b38fbb52bd07cbc0f69ec85912
2026-07-19-web-styling-system.zh.md: 59013a4a950196f3a065ac18415f9b5ed42f3ec3
2026-07-19-web-styling-system.md: b4d647924ab6ab172cd7a7e2531a10a2a7e62981
2026-07-19-web-styling-system.zh.md: 01064d4d52b3ed2b179a4795f5113b94480945bd
@@ -2,7 +2,7 @@
Status: implemented
> Token-system update (2026-07-22): the framework rulings here (CSS Modules + clsx, no component library, no tailwind, tokens-only colors) remain in force, but the two-layer `--bg-*`/`--text-*` token table and its `web-ui/src/style/global.css` home were replaced by the `--dsw-*` static+alias sheets in `packages/client/ui-theme/src/styles/` (dark = `body[data-ds-dark-theme]` override). Current authority: `missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §15.
> Token-system update (2026-07-22): the framework rulings here (CSS Modules + clsx, no component library, no tailwind, tokens-only colors) remain in force, but the two-layer `--bg-*`/`--text-*` token table and its `web-ui/src/style/global.css` home were replaced by the `--dsw-*` static+alias sheets in `packages/client/ui-theme/src/styles/` (dark = `body[data-ds-dark-theme]` override) — the sheets themselves are the token authority.
English | [中文](2026-07-19-web-styling-system.zh.md)
@@ -2,7 +2,7 @@
Status: implemented
> token 体系更新(2026-07-22):本文框架裁决(CSS Modules + clsx、无组件库、无 tailwind、组件只用 token)仍然生效,但两层 `--bg-*`/`--text-*` token 表及其宿主 `web-ui/src/style/global.css` 已被 `packages/client/ui-theme/src/styles/` 的 `--dsw-*` static+alias 双层表取代(暗色=`body[data-ds-dark-theme]` 覆写)。现行权威:`missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §15
> token 体系更新(2026-07-22):本文框架裁决(CSS Modules + clsx、无组件库、无 tailwind、组件只用 token)仍然生效,但两层 `--bg-*`/`--text-*` token 表及其宿主 `web-ui/src/style/global.css` 已被 `packages/client/ui-theme/src/styles/` 的 `--dsw-*` static+alias 双层表取代(暗色=`body[data-ds-dark-theme]` 覆写)——样式表本身即 token 权威
[English](2026-07-19-web-styling-system.md) | 中文
@@ -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-20-gui-testing-system.md: db1b47566f5aa089ffcb10d130ecde1851b93112
2026-07-20-gui-testing-system.zh.md: 691c6baf50c1025a09461effd28ac0f1650fb933
2026-07-20-gui-testing-system.md: fdd5c7f9d33f9a90ea4afe145265be5fe93e0fc2
2026-07-20-gui-testing-system.zh.md: 0ae08133742711b87e9155ddc6f3104b757c1b55
@@ -2,7 +2,7 @@
Status: implemented
> Path update (2026-07-22, plugin-system refactor): the three-tier philosophy and golden-path method here remain current; homes moved — object-layer specs now live in `packages/client/runtime/tests/` (was web-runtime), wire specs in `packages/client/connection/tests/`, and the `web-ui` coverage exclusion is gone with the package (component specs are per-plugin jsdom suites under each `packages/client/*/tests/`). Current test-system authority: `missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §18.
> Path update (2026-07-22, plugin-system refactor): the three-tier philosophy and golden-path method here remain current; homes moved — object-layer specs now live in `packages/client/runtime/tests/` (was web-runtime), wire specs in `packages/client/connection/tests/`, and the `web-ui` coverage exclusion is gone with the package (component specs are per-plugin jsdom suites under each `packages/client/*/tests/`). Component-spec shape follows the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md): feed props directly — the store share comes from `createXXXStore().create()` (the real engine, the sanctioned zero-machinery path), framework hooks are plain stubs; no render machinery, no provider mounting. Slot ownership/registry semantics are tier-2 territory (`runtime` + `ui-slots` suites), not component specs.
English | [中文](2026-07-20-gui-testing-system.zh.md)
@@ -2,7 +2,7 @@
Status: implemented
> 路径更新(2026-07-22,插件体系重构):本文三层理念与金路径方法仍为现行;家搬了——对象层 spec 现居 `packages/client/runtime/tests/`(原 web-runtime)、wire spec 现居 `packages/client/connection/tests/``web-ui` 覆盖豁免随包消亡(组件 spec 为各 `packages/client/*/tests/` 的 jsdom 套件)。测试体系现行权威:`missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §18
> 路径更新(2026-07-22,插件体系重构):本文三层理念与金路径方法仍为现行;家搬了——对象层 spec 现居 `packages/client/runtime/tests/`(原 web-runtime)、wire spec 现居 `packages/client/connection/tests/``web-ui` 覆盖豁免随包消亡(组件 spec 为各 `packages/client/*/tests/` 的 jsdom 套件)。组件 spec 形态遵循 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.md)props 直喂——store 份额来自 `createXXXStore().create()`(真引擎,获认可的零机械路径),框架 hook 用普通桩;无渲染机械、不挂 provider。坑位归属/注册表语义归 2 层地界(`runtime` + `ui-slots` 套件),不归组件 spec
[English](2026-07-20-gui-testing-system.md) | 中文
+1 -1
View File
@@ -1,6 +1,6 @@
# Web GUI 样式规范
> **【token 体系已换代——§1 表格仅历史参考】** 本文的 `--bg-*`/`--text-*`/`--accent` token 族与其宿主包 `packages/client/web-ui` 已随插件化重构退役。现行 token 唯一来源=`packages/client/ui-theme/src/styles/` 的 `--dsw-*` 体系(static 色阶+alias 语义层,暗色=`body[data-ds-dark-theme]` 覆写);组件对账基准=`missions/tasks/20260721-1520-web-plugin-rfc/style-spec.md`。**仍然有效**:工程约束(CSS Modules + clsx、无组件库、无 tailwind、组件禁 hardcode 色值)、字号成对写行高、间距 4 倍数、代码字体栈末位不放 monospace——这些已收编进 architecture.md §15
> **【token 体系已换代——§1 表格仅历史参考】** 本文的 `--bg-*`/`--text-*`/`--accent` token 族与其宿主包 `packages/client/web-ui` 已随插件化重构退役。现行 token 唯一来源=`packages/client/ui-theme/src/styles/` 的 `--dsw-*` 体系(static 色阶+alias 语义层,暗色=`body[data-ds-dark-theme]` 覆写)sheet 即权威、组件对账以它为准。**仍然有效**:工程约束(CSS Modules + clsx、无组件库、无 tailwind、组件禁 hardcode 色值)、字号成对写行高、间距 4 倍数、代码字体栈末位不放 monospace。
> 状态:原「活文档」(随 `packages/client/web-ui` 演进)。视觉基线源自对 deepseekchat 前端仓的实测调研。框架决策与工程约束由 [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) 拍板,本文不重复论证。
+31 -30
View File
@@ -1,50 +1,51 @@
# AGENTS.md — Web client stack
Rules for `packages/client/*` (the browser side of the dsh web GUI) plus its build entry `apps/web`. They supplement the repo-wide [conventions](../../AGENTS.md#conventions) and the [package rules](../README.md); read the two architecture notes linked below before structural changes.
Rules for `packages/client/*` (the browser side of the dsh web GUI) plus its build entry `apps/web`. They supplement the repo-wide [conventions](../../AGENTS.md#conventions) and the [package rules](../README.md). Before touching slots, component props, stores, or plugin structure, read the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) (the definitive composition model) and the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) (loading chain, object layer, services).
Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-<name>`.
## Slot and props discipline
The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) owns the full design; these are the rules you must not violate when writing or reviewing client code:
1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`.
2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking.
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + `useSessions`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useStore`, `renderSlot` are the four seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hooks, no ReactNode producers, no whole-service objects. Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
## Export discipline (client plugin packages)
The `/client` surface of a UI plugin package is a contract face, not a convenience barrel. Three rules, enforced package-wide (do not restate them as per-file comments):
1. **A UI plugin exports no values beyond what cordis loading needs**`apply` / `inject` (and `Config` where present). Types are the extra allowance: contract types (OwnerShare shapes, injected shapes, view/toolview entry types) export freely. Implementation components, pure helpers, constants, and stores stay internal. Existing value exports beyond this line (the ui-layout frame components consumed by the shell assembler, service classes kept for `import type`) are grandfathered per-consumer; adding a new one requires user sign-off, not a matching export.
1. **A UI plugin exports no values beyond what cordis loading needs**`apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType<typeof createXXXStore>`). Types are the extra allowance: contract types (owner shares, injected shapes, view/toolview entry types) export freely. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer.
2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public surface to make a test compile.
3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (define/register/renderSlot, the view and toolview registries) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself.
3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot, the view and toolview registries) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself.
## ctx discipline (components never see ctx)
`ctx` belongs to the apply world only: the plugin body and the inject factories closed over it. Components — every `.tsx` under a feature domain — receive all data and callbacks **through the four props shares**; they never call a hook that reaches ctx, never import a service class to poke it, never read a React context (business components see zero contexts — `BindingContext` and its kin are renderer-internal). If a component needs something new, the answer is a prop threaded from its share's source (owner site, store declaration, or inject face), not a hook.
## Layering red lines
The stack is three layers with one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md):
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** (`web-runtime`, React-free): `ConnectionController``SessionManager``Session` own all business state (event windows, streaming accumulation, reconnect machine). Zero React imports — grep-assertable.
2. **Hooks layer** (`web-ui/src/hooks`, pure data): subscribes to object snapshots via `useSyncExternalStore`, exposes plain-data handles. No JSX, no DOM.
3. **Presentation components** (`web-ui`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; they receive data and callbacks through props only.
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.
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:
- **No business objects in the store.** zustand carries cross-view presentation state only (`rpcLog`, `ui`, `connection` slices). Sessions, frames, and connections live in the object layer. View-local facts (selection, expansion) stay in component state, not the store.
- **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer.
- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest<P>`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)).
- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `web-runtime/src/session/notifier.ts`.
- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `runtime/src/client/sessions/notifier.ts`.
- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
## Directory regime (`web-ui/src`)
## Directory regime (plugin packages)
> Shell restructure in progress: the tree is converging to this layout (today's `components/{conversation,sessions,panels}` migrate into it); the regime below is the target every new feature follows now.
Two-level feature directories, one contributor per directory — physical conflict avoidance:
```
web-ui/src/
shell/ # AppShell + the three slot registries + builtins
leftmenu/<bar>/ # one directory per left-nav bar (sessions, rpclog, …)
sessiontabs/<tab>/ # one directory per session tab (conversation, gantt, …)
components/ # shared leaves (MessageText, JsonBlock, …)
hooks/ utils/ style/ # cross-cutting; not feature-owned
```
- `leftmenu/<a>` must not import `leftmenu/<b>` or `sessiontabs/*` (and vice versa). Anything two features need sinks into `components/`.
- Bars, tabs, and detail blocks register through the `shell/` registries (module-level map, `register*()` returns the disposer — same shape as `toolCardRegistry`). v1 registration is static in `shell/builtins.ts`; plugin-driven registration later calls the same functions.
- **Claiming a placeholder slot**: pick a `placeholder: true` tab (or add a bar) in `shell/builtins.ts`, create your feature directory, and replace the placeholder component with your container. Don't build features outside this regime.
One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through the slot/view/toolview registries in `apply` — never module-level side effects.
## Styling
@@ -71,9 +72,9 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
## New component checklist
1. Claim the slot (see the directory regime above): one feature, one directory.
2. Build the container in your feature directory; keep leaves pure-props. Wire data through the hooks layer, not by importing business objects into components.
3. Copy a neighbouring jsdom spec into `web-ui/tests/`, keep it behavior-shaped: start from the happy path and the edge states, then widen until the component's branches are covered — the coverage gate applies; only the assertion style stays behavior-level.
1. Compose through register: merge the slot contract into `SlotMap`, declare the slot in its parent entry's `children`, register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists.
2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local.
3. Component tests feed props directly (`createXXXStore().create()` for the store share; plain stubs for framework hooks) — behavior-shaped assertions, no render machinery.
4. Tokens only in CSS; Chinese product copy; English comments.
5. `pnpm run test:gui` green (plus `test:web` if you touched the build surface).
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the three GUI notes above are the precedents to extend.
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend.
+5 -2
View File
@@ -5,8 +5,11 @@
* Contract: api-contracts v3 section 8.
*/
import type { Context } from 'cordis'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
// 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'
import { en } from '../locales/en.ts'
import { zh } from '../locales/zh.ts'
+29 -7
View File
@@ -1,21 +1,27 @@
/**
* Browser half: the whole runtime contract surface (api-contracts v3 §4) —
* SlotsService, SessionsService (list store + scope tree + object layer),
* the ClientLoader interface, and the cordis Context/Events merges. apply
* SlotsService (declaration ledger + renderer seam + store axis, built-in
* 'root'), SessionsService (list store + current selection + scope tree +
* object layer), the ClientLoader interface, and the cordis Context/Events
* merges. apply
* mounts ctx.slots + ctx.sessions and wires the connection stream loop into
* the object layer. The loader machinery implementation is NOT in the plugin
* bundle — it ships via the package's `./loader` subpath, statically held by
* the web shell (a loader cannot load itself).
*/
import type { Context } from 'cordis'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionBinding as GenericSessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
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 { SlotsService } from './slots.ts'
import { SessionsService } from './sessions/service.ts'
import type { SessionListState } from './sessions/service.ts'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
export { SlotsService } from './slots.ts'
// RootOwnerProps rides the 'root' SlotMap row (both migrated here from
// ui-layout: the framework slot is declared by the framework package).
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'
@@ -38,9 +44,6 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
*/
export type ClientContext = Context
/** SessionBinding narrowed to the client context (inject factories dot services directly). */
export type ClientSessionBinding = GenericSessionBinding<ClientContext>
/** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */
export type UseConversationSession = UseSession<ConversationSnapshot>
@@ -51,6 +54,25 @@ export type UseConversationSession = UseSession<ConversationSnapshot>
*/
export type ToolCallBlock = RunningToolCall | ToolResultNode
declare module '@deepseek-ai/dsh-client-ui-slots' {
/**
* Session standard kit, real members (ui-slots declares the empty seat;
* the runtime — where the subjects live — merges the concrete types):
* every session-scope slot component receives these from the framework.
*/
interface SessionStandardProps {
/** Selector hook over this session's conversation snapshot. */
useSession: SnapshotSelectorHook<ConversationSnapshot>
/** The framework-resolved session id (owners never pass it). */
sessionId: SessionId
}
/** Global standard kit, real members: the session-list hook every slot component receives. */
interface GlobalStandardProps {
/** Selector hook over the session list snapshot (`current` included — the arbitrated selection seat). */
useSessions: SnapshotSelectorHook<SessionListState>
}
}
declare module 'cordis' {
interface Events {
/**
@@ -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'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
@@ -1,7 +1,9 @@
/**
* SessionsService: root sessions service — list snapshot store (manager
* projection), session scope tree (mintScope pattern: no-op plugin Fiber +
* ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
* projection; carries `current`, the persisted selection every
* session-scoped surface keys off — migrated here from ui-layout per the
* slot-parity design), session scope tree (mintScope pattern: no-op plugin
* Fiber + ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
*
* Scope lifecycle is watch-driven: a scope is minted lazily on first
* resolution; a session leaving the list tears its scope down only when
@@ -13,8 +15,11 @@
*/
import type { Context, Fiber } from 'cordis'
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
// 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 { SessionManager } from './manager.ts'
import type { Session } from './session.ts'
@@ -28,8 +33,12 @@ export interface SessionSummary {
updatedAt: number
}
/** Session list store shape. */
export interface SessionListState { ids: SessionId[]; byId: Record<SessionId, SessionSummary> }
/**
* Session list store shape. `current` rides the same snapshot (arbitrated:
* the single useSessions standard hook reads list and selection together —
* sidebar highlighting and SessionProvider share one fact source).
*/
export interface SessionListState { ids: SessionId[]; byId: Record<SessionId, SessionSummary>; current: SessionId | undefined }
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
export interface SessionBinding {
@@ -69,15 +78,26 @@ interface ScopeRecord {
fiber: Fiber
ctx: Context
binding: SessionBinding
/** Render-layer standard kit (identity-stable per scope; the renderer's per-cell caches key off it). */
cell: SessionCell
}
/** Root sessions service: list store, object-layer manager, scope tree, bindings, ancestry. */
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
export class SessionsService {
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect). */
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
readonly list: SnapshotStore<SessionListState>
/** The object-layer instance cluster and frame dispatch entry (wired to the connection by the runtime apply). */
readonly manager: SessionManager
/**
* Persisted selection cell (the durable half of `list.current`). Private on
* purpose: reads go through the list snapshot; writes through {@link
* SessionsService.open}. Projection validates it against the live list
* instead of destructively pruning, so a selection survives transient list
* states (reconnect re-pull) and resurfaces when its session returns.
*/
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
private readonly scopes = new Map<SessionId, ScopeRecord>()
/** Most recently resolved binding id — the watch approximation for deferred teardown. */
private watched: SessionId | undefined
@@ -90,13 +110,29 @@ export class SessionsService {
*/
constructor(private readonly rootCtx: Context, api: IApiClient) {
this.manager = new SessionManager(api)
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
{},
{ persist: { name: 'dsh.sessions.current' } })
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined })
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
this.manager.subscribe(() => { this.projectList() })
rootCtx.reflect.provide('sessions', this, undefined)
}
/**
* Select a session as current. Unknown ids fail loud instead of navigating
* nowhere (the sole selection write path).
* @param id - session id (must exist in the list store).
*/
open(id: SessionId): void {
if (this.list.getSnapshot().byId[id] === undefined) {
throw new Error(`sessions.open: unknown session ${id}`)
}
this.selection.update((draft) => { draft.sessionId = id })
this.list.update((draft) => { draft.current = id })
}
/**
* Create a session on the host.
* @param opts - creation options (project directory).
@@ -132,6 +168,23 @@ export class SessionsService {
return record.binding
}
/**
* Resolve the render-layer session cell (SessionProvider's feed through
* the renderer host; ctx never enters the render layer). Marks the session
* watched, same as {@link SessionsService.binding}.
* @param id - session id.
* @returns cell, or undefined for a session neither listed nor already scoped.
*/
cell(id: string): SessionCell | undefined {
const record = this.resolve(id as SessionId)
if (record === undefined) return undefined
if (this.watched !== id) {
this.watched = id as SessionId
this.sweepDeferred()
}
return record.cell
}
/**
* Breadcrumb feed: walk parentId links inside the list store.
* @param id - session id.
@@ -158,10 +211,12 @@ export class SessionsService {
if (this.list.getSnapshot().byId[id] === undefined) return undefined
const fiber = this.rootCtx.plugin(sessionScope)
const ctx = fiber.ctx.extend({ [kScope]: id })
const session = this.manager.get(id)
const record: ScopeRecord = {
fiber,
ctx,
binding: { sessionId: id, session: this.manager.get(id), ctx },
binding: { sessionId: id, session, ctx },
cell: { sessionId: id, useSession: session.useSelector },
}
this.scopes.set(id, record)
return record
@@ -183,7 +238,11 @@ export class SessionsService {
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
}
}
this.list.set({ ids, byId })
// current = the persisted selection, masked while its session is absent
// (falls to the empty state; resurfaces if the session returns).
const selected = this.selection.getSnapshot().sessionId
const current = selected !== undefined && byId[selected] !== undefined ? selected : undefined
this.list.set({ ids, byId, current })
this.pruneScopes(byId)
}
@@ -197,10 +256,18 @@ export class SessionsService {
}
this.scopes.delete(id)
this.deferredRemovals.delete(id)
void record.fiber.dispose()
this.dropScope(id, record)
}
}
/** Dispose a scope fiber and its session-keyed slot-store instances together (single lifecycle axis). */
private dropScope(id: SessionId, record: ScopeRecord): void {
void record.fiber.dispose()
// Optional lookup: slots and sessions are sibling services with no
// declared dependency; a slots-less boot (object-layer tests) skips.
this.rootCtx.get('slots')?.pruneStoreScope(id)
}
/** Run deferred teardowns whose session is no longer watched (called when the watch moves). */
private sweepDeferred(): void {
for (const id of [...this.deferredRemovals]) {
@@ -220,7 +287,7 @@ export class SessionsService {
* future teardown path cannot double-dispose. */
if (record !== undefined) {
this.scopes.delete(id)
void record.fiber.dispose()
this.dropScope(id, record)
}
}
}
+295 -44
View File
@@ -1,22 +1,113 @@
/**
* SlotsService: cordis Service wrapper over the pure SlotCore (ui-slots).
* Every mutation re-emits as the 'slots/changed' cordis event; define/register
* run through the caller's ctx.effect so a plugin's registrations are
* collected when its fiber unloads (cordis-native cascade).
* SlotsService: the cordis Service layer of the slot system over the pure
* SlotCore (ui-slots owns registration semantics, the declaration ledger,
* the load-time validations, and the unload cascade). This layer owns what
* needs the runtime: the 'slots/changed' event bridge, register through the
* caller's ctx.effect (fiber unload collects registrations), the renderer
* install seam (install()/renderSlot('root') + the SlotRendererHost face),
* and the store INSTANCE axis — handle x scope key -> create/cache, dropped
* with the last holding entry, session instances cleared (with persisted
* state) on scope death.
*/
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
* in this compilation unit (intersection reads `never`) but consumers merge
* keys in; the rule fires on the empty-map view, not on real redundancy. */
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap only
* holds this package's 'root' row in this compilation unit, but consumers
* merge keys in; the rule fires on the narrow-map view, not on real
* redundancy. */
import { Service } from 'cordis'
import type { Context } from 'cordis'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
import type { ComposedProps, RegisterArgs, SlotComponent, SlotEntry, SlotEntryDef, SlotMap, SlotSpec } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from './index.ts'
import type {
ChildrenDecl, ComposedProps, HandleOf, InjectParams, KindOptions, OwnerOf,
SlotComponent, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
/** cordis Service wrapper over the pure SlotCore; mutations re-emit as 'slots/changed'. */
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** The built-in render-tree root hole (seeded by SlotCore): rendered only by the shell, occupied by a layout entry. */
'root': { kind: 'single'; scope: 'root'; owner: RootOwnerProps }
}
}
/** Root owner share: the shell supplies nothing — the frame is inject-assembled. */
export interface RootOwnerProps { children?: never }
/** Instance key for root-scoped store records (session records key by session id, so the literal cannot collide). */
const ROOT_INSTANCE_KEY = 'root'
// FIXME(slot-parity): the engine's arbitrated persist extensions — create()
// takes the scope key (per-session localStorage suffix) and instances expose
// clearPersisted() — are not yet on ui-slots' StoreHandle/StoreInstanceLike;
// these local structural faces bridge until fw-slots lifts them.
/** Store handle face as the engine actually ships it (scope-key-aware create). */
interface EngineStoreHandle { create(scopeKey?: string): EngineStoreInstance }
/** Engine instance face: the host-contract shape plus persisted-state cleanup. */
interface EngineStoreInstance extends StoreInstanceLike { clearPersisted(): void }
/** Store axis record: one per live handle, dropped when the last holding entry unloads. */
interface StoreAxisRecord {
/** Scope of the slot the handle mounted under (the core validated cross-scope conflicts). */
scope: SlotScope
/** Live registrations holding the handle. */
refs: number
/** Root scope: the single instance under {@link ROOT_INSTANCE_KEY}; session scope: one per session id. */
instances: Map<string, EngineStoreInstance>
}
/**
* Register options as the service face declares them (structurally the
* core's BaseOptions, re-declared because ui-slots keeps it private).
* FIXME(slot-parity): dedupe once ui-slots exports its options type.
*/
type RegisterOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H> = {
/** Target slot key (the entry contributes INTO this slot). */
name: K
/** Child-slot declaration + render authorization + runtime spec, in one table. */
children?: D
/** Store seat: a shared handle (apply-constructed) or an exclusive factory (framework-called per entry). */
store?: H
/** Registrant identity label for diagnostics (defaults to the caller's fiber name). */
registrant?: string
} & KindOptions<SlotMap[K]>
/**
* Compile-time presence check: an entry declaring children MUST consume
* `renderSlot` (declaring is claiming). Structural copy of the core's
* private RendersCheck; same FIXME as {@link RegisterOptions}.
*/
type RendersCheck<C, D> =
[keyof D & keyof SlotMap & string] extends [never] ? unknown
: C extends (props: infer P) => unknown
? ('renderSlot' extends keyof P ? unknown
: { 'children declared but the component consumes no renderSlot': keyof D & keyof SlotMap & string })
: unknown
/** Type-erased options view the implementation works with (the typed overloads proved the shares). */
interface ErasedRegisterOptions {
name: string
children?: Record<string, SlotSpec<SlotEntryDef>>
store?: StoreDecl
inject?: (...args: never[]) => Record<string, unknown>
key?: string
id?: string
order?: number
label?: string
registrant?: string
}
/** Erased core call face (the service re-erases at its own boundary; the core's typed face targets end callers). */
interface ErasedCore { register(options: object, component: unknown): () => void }
/** cordis Service layer of the slot system; see the module doc for the split with SlotCore. */
export class SlotsService extends Service {
private readonly _core = new SlotCore()
/** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */
private readonly _stores = new Map<EngineStoreHandle, StoreAxisRecord>()
private _renderer: SlotRenderer | undefined
private _host: SlotRendererHost | undefined
/**
* @param ctx - owning root context.
@@ -27,44 +118,115 @@ export class SlotsService extends Service {
}
/**
* Record a slot spec (delegates to SlotCore.define; disposal follows the caller's fiber).
* @param key - SlotMap key.
* @param spec - kind/scope spec.
* @returns disposer.
* The single registration API (see SlotCore.register for the full
* semantics: children declaration, store seat, inject face, load-time
* validation, unload cascade). This layer adds: disposal through the
* caller's ctx.effect (fiber unload = cascade), exclusive-factory minting
* (`store: createXxxStore` becomes a per-entry handle), the registrant
* diagnostics stamp, and store-instance lifecycle on the entry axis.
* @param options - name + children + store + inject (+ kind-shaped key/id/order/label).
* @param component - pure component typed by the four-share composed props.
* @returns disposer (idempotent; stale calls after fiber teardown are no-ops).
*/
define<K extends keyof SlotMap & string>(key: K, spec: SlotSpec<SlotMap[K]>): () => void {
register<
K extends keyof SlotMap & string,
const D extends ChildrenDecl = Record<never, never>,
H extends StoreDecl | undefined = undefined,
C extends SlotComponent<never> = SlotComponent<never>,
>(
options: RegisterOptions<K, D, H> & { inject?: undefined },
component: C
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object>>
& RendersCheck<C, D>,
): () => void
register<
K extends keyof SlotMap & string,
I extends object,
const D extends ChildrenDecl = Record<never, never>,
H extends StoreDecl | undefined = undefined,
C extends SlotComponent<never> = SlotComponent<never>,
>(
options: RegisterOptions<K, D, H> & { inject: (...args: InjectParams<K, H>) => I },
component: C
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I>>
& RendersCheck<C, D>,
): () => void
register(rawOptions: object, component: unknown): () => void {
// The typed overloads above proved the shares; the implementation works
// on the erased view (same pattern as the core's register).
const options = rawOptions as ErasedRegisterOptions
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return this.ctx.effect(() => this._core.define(key, spec), 'slots.define()')
return this.ctx.effect(() => this._register(options, component), 'slots.register()')
}
/**
* Contribute a component (delegates to SlotCore.register; disposal follows the caller's fiber).
* @param key - SlotMap key.
* @param component - contributed component.
* @param args - kind-shaped options (mandatory for keyed/list kinds); the
* inject factory's binding is pinned to ClientContext.
* @returns disposer.
* Install the shell's renderer (web-react's createSlotRenderer product).
* Boot-once: a second install throws. Runs through the caller's ctx.effect,
* so shell fiber unload uninstalls the renderer.
* @param renderer - the outlet machinery implementing SlotRenderer.
*/
register<K extends keyof SlotMap & string, I extends object = Record<string, unknown>>(
// Client-context registrations have exactly one ctx shape: pin Ctx to
// ClientContext so inject factories dot services without a cast.
key: K, component: SlotComponent<ComposedProps<K, NoInfer<I>>>,
...args: RegisterArgs<SlotMap[K], I, ClientContext>): () => void {
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return this.ctx.effect(() => this._core.register<K, I, ClientContext>(key, component, ...args), 'slots.register()')
install(renderer: SlotRenderer): void {
if (this._renderer !== undefined) throw new Error('slot renderer already installed (install() is boot-once)')
this.ctx.effect(() => {
this._renderer = renderer
return () => {
if (this._renderer === renderer) this._renderer = undefined
}
}, 'slots.install()')
}
/**
* Snapshot entries for a key.
* @param key - SlotMap key.
* @returns registered entries (stable reference between mutations).
* The single ctx-level render entry: the shell renders 'root'; every other
* key renders inside components through the props renderSlot face. All
* three guards are fail-loud boot-order checks, no fallback.
* @param key - must be 'root' (runtime-enforced for dynamically composed callers).
* @param owner - owner share for the root entry (the shell supplies {}).
* @returns the rendered root tree.
*/
entries<K extends keyof SlotMap & string>(key: K): readonly SlotEntry<SlotMap[K]>[] {
renderSlot<K extends keyof SlotMap & string>(key: K, owner: OwnerOf<K>): ReturnType<SlotRenderer['renderRoot']> {
// Widened: in this package's own program SlotMap holds only 'root', which
// would fold the guard to constant-false; the check exists for plain-JS
// and cross-program callers where K is wider.
if ((key as string) !== 'root') {
throw new Error(`ctx-level renderSlot only renders 'root' (got "${key}"); child slots render through the component props face`)
}
if (this._renderer === undefined) {
throw new Error("slot renderer not installed — boot must call ctx.slots.install(createSlotRenderer()) before rendering 'root'")
}
if (this._core.entries('root').length === 0) {
throw new Error("'root' has no registration — a layout entry must register into 'root' before the shell renders it")
}
return this._renderer.renderRoot(this.hostFace(), owner)
}
/**
* Drop the per-session store instances of a dead session (the sessions
* service calls this on scope teardown; root-scoped records are untouched).
* Persisted state goes with the session — a never-rendered dead session can
* still own keys from an earlier page load, so the instance is materialized
* transiently just to clear storage (no-op for unpersisted stores).
* @param sessionId - the torn-down session.
*/
pruneStoreScope(sessionId: string): void {
for (const [handle, record] of this._stores) {
if (record.scope !== 'session') continue
const instance = record.instances.get(sessionId) ?? handle.create(sessionId)
instance.clearPersisted()
record.instances.delete(sessionId)
}
}
/**
* Snapshot entries for a key (render-erased view; stable reference between mutations).
* @param key - SlotMap key.
* @returns registered entries.
*/
entries(key: keyof SlotMap & string): readonly StoredEntry[] {
return this._core.entries(key)
}
/**
* Look up a defined spec.
* Look up a declared spec (register-declared or the built-in 'root').
* @param key - SlotMap key.
* @returns spec or undefined.
*/
@@ -72,15 +234,6 @@ export class SlotsService extends Service {
return this._core.spec(key)
}
/**
* Dynamic-key escape hatch for spec lookup (renderer-side string keys).
* @param key - candidate slot key.
* @returns wide-typed spec or undefined.
*/
specDynamic(key: string): SlotSpec<SlotEntryDef> | undefined {
return this._core.specDynamic(key)
}
/**
* Subscribe to a key's registration changes (microtask-batched).
* @param key - SlotMap key.
@@ -100,8 +253,106 @@ export class SlotsService extends Service {
return this._core.getVersion(key)
}
/** The wrapped pure core (web-react's scopedSlots outlet reads through this). */
/** The wrapped pure core (invariant checks read through this). */
get core(): SlotCore {
return this._core
}
/** Delegating registration path: factory minting + registrant stamp + core write + instance-axis bookkeeping. */
private _register(options: ErasedRegisterOptions, component: unknown): () => void {
// Exclusive stores pass the factory itself: minted here into a per-entry
// handle so the stored entry always carries a resolvable handle (the
// core's shared-handle scope pinning applies to it harmlessly).
const store = typeof options.store === 'function' ? options.store() : options.store
const registrant = options.registrant ?? (this.ctx.fiber as { name?: string } | undefined)?.name
const erased: ErasedRegisterOptions = {
...options,
...(store !== undefined ? { store } : {}),
...(registrant !== undefined ? { registrant } : {}),
}
// Core write first: all load-time validation (undeclared target,
// duplicate declaration, kind conflicts, cross-scope handle) throws
// there before this layer commits anything.
const dispose = (this._core as unknown as ErasedCore).register(erased, component)
if (store !== undefined) {
// Register succeeded, so the target's spec is on the ledger.
const scope = (this._core.specDynamic(options.name) as SlotSpec<never>).scope
this._acquire(store, scope)
}
let disposed = false
return () => {
if (disposed) return
disposed = true
dispose()
if (store !== undefined) this._release(store)
}
}
/** Build (once) the host face the installed renderer reads; sessions resolve lazily at first render. */
private hostFace(): SlotRendererHost {
if (this._host !== undefined) return this._host
const sessions = this.ctx.get('sessions')
if (sessions === undefined) {
throw new Error("renderSlot('root') before the sessions service mounted — boot order puts runtime apply first")
}
// Identity-stable view: current rides the list snapshot (arbitrated), but
// the provider consumes it as its own observable; one cached object keeps
// the renderer's per-source hook cache stable.
const current = {
getSnapshot: () => sessions.list.getSnapshot().current as string | undefined,
subscribe: (fn: () => void) => sessions.list.subscribe(fn),
}
this._host = {
subscribe: (key, fn) => this._core.subscribe(key, fn),
getVersion: key => this._core.getVersion(key),
entriesOf: key => this._core.entries(key),
specOf: key => this._core.specDynamic(key),
isLive: entry => this._core.isLive(entry),
storeOf: (entry, scopeKey) =>
entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey),
sessions: {
list: sessions.list,
current,
cell: id => sessions.cell(id),
},
}
return this._host
}
/** Resolve (create or reuse) the store instance for a registered handle under a scope key. */
private resolveStore(handle: EngineStoreHandle, sessionId: string | undefined): StoreInstanceLike {
const record = this._stores.get(handle)
if (record === undefined) throw new Error('store handle is not registered (entry unloaded, or the handle never went through register)')
const key = record.scope === 'session' ? sessionId : ROOT_INSTANCE_KEY
if (key === undefined) throw new Error('session-scoped store resolution requires a session id')
let instance = record.instances.get(key)
if (instance === undefined) {
// Session instances get the scope key (the engine suffixes the persist
// key per session); root instances stay keyless.
instance = record.scope === 'session' ? handle.create(key) : handle.create()
record.instances.set(key, instance)
}
return instance
}
/** Bind (or re-reference) a handle on the axis; cross-scope conflicts already threw in the core. */
private _acquire(handle: EngineStoreHandle, scope: SlotScope): void {
const record = this._stores.get(handle)
if (record === undefined) {
this._stores.set(handle, { scope, refs: 1, instances: new Map() })
return
}
record.refs += 1
}
/** Drop one reference; the last holder's unload drops the record (instances go with it — engine stores need no explicit dispose). */
private _release(handle: EngineStoreHandle): void {
const record = this._stores.get(handle)
/* v8 ignore next -- defensive: release only runs from a disposer whose
* register acquired the same handle, so the record must exist; kept so a
* future call site cannot underflow the axis. */
if (record === undefined) return
record.refs -= 1
if (record.refs === 0) this._stores.delete(handle)
}
}
@@ -37,6 +37,9 @@ describe('runtime client apply', () => {
it('mounts ctx.slots + ctx.sessions and wires the stream sinks into the manager', async () => {
const bench = await mount()
expect(bench.ctx.get('slots') !== undefined).toBe(true)
// The built-in 'root' declaration ships with this package's SlotsService
// (the SlotMap 'root' merge lives here since the slot-parity rework).
expect(bench.ctx.slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
const sessions = bench.ctx.get('sessions')
expect(sessions !== undefined).toBe(true)
expect(bench.sinks).toBeDefined()
@@ -25,9 +25,11 @@ describe('runtime slots/changed invariant', () => {
const ctx = await setup()
expect(() => { emit(ctx, 'unrelated/event', 'x') }).not.toThrow()
await ctx.plugin(SlotsService).await() // fiber must reach ACTIVE — the audit reads strict ctx.get
// A real define bumps the version first and re-emits through onMutate —
// the audit sees version > 0 and stays quiet.
expect(() => ctx.slots.define('t-single', { kind: 'single', scope: 'root' })).not.toThrow()
// A real registration bumps the version first and re-emits through
// onMutate — the audit sees version > 0 and stays quiet. (Erased call:
// the typed register face rides the wave-1 ui-slots types.)
const slots = ctx.slots as unknown as { register(options: object, component: unknown): () => void }
expect(() => slots.register({ name: 'root' }, () => null)).not.toThrow()
})
it('fails loud on a missing key and on an emission with no applied mutation', async () => {
@@ -1,10 +1,12 @@
/**
* SessionsService: list store projection (manager → {ids, byId} with derived
* titles), scope-tree lifecycle (lazy mint / frozen survival / removed
* teardown with watch deferral), binding identity, ancestry walk, create.
* SessionsService: list store projection (manager → {ids, byId, current}
* with derived titles), the migrated current-selection account (open
* validation, persisted mask semantics, cell resolution), scope-tree
* lifecycle (lazy mint / frozen survival / removed teardown with watch
* deferral), binding identity, ancestry walk, create.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService, scopeOf } from '../src/client/sessions/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
@@ -112,6 +114,95 @@ describe('scope tree', () => {
})
})
describe('current selection (migrated from ui-layout, arbitrated into the list snapshot)', () => {
afterEach(() => { vi.unstubAllGlobals() })
it('open() writes list.current; unknown ids fail loud', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
expect(b.svc.list.getSnapshot().current).toBeUndefined()
b.svc.open(sid('s1'))
expect(b.svc.list.getSnapshot().current).toBe('s1')
expect(() => { b.svc.open(sid('ghost')) }).toThrow(/unknown session ghost/)
expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone
})
it('masks (not destroys) the selection while its session is off the list', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.open(sid('s1'))
await feedList(b, [{ id: 's2' }]) // s1 removed → current falls to the empty state
expect(b.svc.list.getSnapshot().current).toBeUndefined()
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // s1 returns → selection resurfaces
expect(b.svc.list.getSnapshot().current).toBe('s1')
})
it('persists the selection under dsh.sessions.current and rehydrates it into a fresh service', async () => {
const storage = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (k: string) => storage.get(k) ?? null,
setItem: (k: string, v: string) => { storage.set(k, v) },
})
const first = bench()
await feedList(first, [{ id: 's1' }])
first.svc.open(sid('s1'))
expect(storage.get('dsh.sessions.current')).toContain('s1')
// A fresh boot (same storage) recovers the selection once the list holds the session.
const second = bench()
await feedList(second, [{ id: 's1' }])
expect(second.svc.list.getSnapshot().current).toBe('s1')
})
})
describe('cell (render-layer session kit)', () => {
it('resolves an identity-stable {sessionId, useSession} pair; 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)
expect(b.svc.cell('s1')).toBe(cell)
expect(b.svc.cell('ghost')).toBeUndefined()
})
it('moves the watch like binding(): switching cells sweeps a deferred removal', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.cell('s1') // watched
await feedList(b, []) // removed while watched → deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBeDefined()
await feedList(b, [{ id: 's2' }])
b.svc.cell('s2') // watch moves → sweep tears s1 down
expect(b.svc.scope(sid('s1'))).toBeUndefined()
})
})
describe('slot-store scope prune hook', () => {
it('notifies ctx.slots.pruneStoreScope when a scope dies (both teardown paths)', async () => {
const b = bench()
const pruneStoreScope = vi.fn()
b.ctx.reflect.provide('slots', { pruneStoreScope })
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.scope(sid('s1'))
b.svc.binding(sid('s2')) // s2 watched
await feedList(b, []) // s1 unwatched → immediate drop; s2 watched → deferred
expect(pruneStoreScope).toHaveBeenCalledWith('s1')
expect(pruneStoreScope).not.toHaveBeenCalledWith('s2')
await feedList(b, [{ id: 's3' }])
b.svc.binding(sid('s3')) // watch moves → deferred sweep drops s2
expect(pruneStoreScope).toHaveBeenCalledWith('s2')
})
it('tolerates a slots-less boot (object-layer benches carry no slot service)', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.scope(sid('s1'))
await feedList(b, []) // teardown without ctx.slots must not throw
expect(b.svc.scope(sid('s1'))).toBeUndefined()
})
})
describe('ancestry', () => {
it('walks parentId links root-first including self; broken links stop the walk', async () => {
const b = bench()
@@ -1,80 +1,379 @@
/**
* SlotsService: cordis Service wrapper semantics — core delegation, the
* 'slots/changed' event bridge, and fiber-scoped registration disposal.
* SlotsService terminal-design account (design.md §11-3 main landing):
* built-in 'root', the three load-time throws (duplicate declaration /
* undeclared contribution / cross-scope store handle), the renderer install
* seam (double install / not installed / non-root key), store instance
* resolution and lifecycle on the ledger axis, and the entry-unload cascade.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from '../src/client/slots.ts'
// Test-only slot keys (SlotMap is empty in this package; the service is generic over it).
// Test-only slot keys (merged so the typed entries/spec faces accept them).
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
't-single': { kind: 'single'; scope: 'root'; props: object }
't-list': { kind: 'list'; scope: 'root'; props: object }
't.host': { kind: 'single'; scope: 'root' }
't.panel': { kind: 'single'; scope: 'session' }
't.rows': { kind: 'list'; scope: 'root' }
}
}
const C: FC<object> = () => null
async function boot(): Promise<Context> {
/**
* Register/install/renderSlot through a type-erased view: the typed register
* face rides wave-1 ui-slots types (red until that wave lands); the runtime
* semantics under test are final.
*/
interface ErasedService {
register(options: object, component: unknown): () => void
install(renderer: object): void
renderSlot(key: string, owner: object): unknown
}
interface Bench {
ctx: Context
svc: SlotsService
erased: ErasedService
}
async function boot(): Promise<Bench> {
const ctx = new Context()
ctx.plugin(SlotsService)
await ctx.fiber.await()
return ctx
// Service accessor (ctx.get reads the reflect store, which Service-class
// plugins do not write; the accessor is the product path).
const svc = ctx.slots
return { ctx, svc, erased: svc as unknown as ErasedService }
}
describe('SlotsService', () => {
it('proxies define/register/entries/spec/getVersion to the core', async () => {
const ctx = await boot()
ctx.slots.define('t-single', { kind: 'single', scope: 'root' })
expect(ctx.slots.spec('t-single')).toEqual({ kind: 'single', scope: 'root' })
const v0 = ctx.slots.getVersion('t-single')
ctx.slots.register('t-single', C)
expect(ctx.slots.entries('t-single')).toHaveLength(1)
expect(ctx.slots.getVersion('t-single')).toBeGreaterThan(v0)
expect(ctx.slots.core.spec('t-single')).toBeDefined()
/** Engine-shaped instance stub (the arbitrated persist face: scope-keyed create + clearPersisted). */
interface FakeInstance {
useSelector: () => undefined
actions: Record<string, never>
clearPersisted: ReturnType<typeof vi.fn>
}
/** Fake store handle factory (create-count and clearPersisted observable). */
function fakeHandle() {
const created: FakeInstance[] = []
const handle = {
create: vi.fn((_scopeKey?: string): FakeInstance => {
const instance: FakeInstance = { useSelector: () => undefined, actions: {}, clearPersisted: vi.fn() }
created.push(instance)
return instance
}),
}
return { handle, created }
}
/**
* Install a capturing renderer, occupy 'root' (declaring `children` in the
* same call — 'root' is single, so the one occupant is also the declarer),
* and pull the host face out through renderSlot('root').
*/
function captureHost(bench: Bench, children?: object): SlotRendererHost {
let host: SlotRendererHost | undefined
bench.erased.install({
renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
})
bench.erased.register({ name: 'root', ...(children !== undefined ? { children } : {}) }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.erased.renderSlot('root', {})
if (host === undefined) throw new Error('renderer never received the host')
return host
}
/** Minimal sessions face for the host seam (list observable + cell). */
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),
}
}
describe("built-in 'root'", () => {
it('is declared at construction: spec readable, occupancy open, no plugin needed', async () => {
const bench = await boot()
expect(bench.svc.spec('root')).toEqual({ kind: 'single', scope: 'root' })
expect(() => bench.erased.register({ name: 'root' }, C)).not.toThrow()
expect(bench.svc.entries('root')).toHaveLength(1)
})
it("re-emits every mutation as 'slots/changed' with the key", async () => {
const ctx = await boot()
const seen: string[] = []
ctx.on('slots/changed', (key) => { seen.push(key) })
ctx.slots.define('t-list', { kind: 'list', scope: 'root' })
ctx.slots.register('t-list', C, { id: 'a' })
expect(seen).toEqual(['t-list', 't-list'])
it('rejects a second declaration of root, attributing the built-in row', async () => {
const bench = await boot()
expect(() => bench.erased.register({
name: 'root', children: { 'root': { kind: 'single', scope: 'root' } },
}, C)).toThrow(/already declared.*built-in/)
})
})
describe('load-time validation', () => {
it('throws on contributing into an undeclared slot', async () => {
const bench = await boot()
expect(() => bench.erased.register({ name: 't.host' }, C)).toThrow(/slot "t.host" is not declared/)
})
it('collects a plugin fiber\'s registrations when the fiber unloads (cascade)', async () => {
const ctx = await boot()
ctx.slots.define('t-single', { kind: 'single', scope: 'root' })
const fiber = ctx.plugin({
it('throws on a duplicate declaration, naming the slot and the prior declarant', async () => {
const bench = await boot()
bench.erased.register({ name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } } }, C)
bench.erased.register({
name: 't.host', children: { 't.rows': { kind: 'list', scope: 'root' } },
}, C)
expect(() => bench.erased.register({
name: 't.rows', id: 'r1', children: { 't.rows': { kind: 'list', scope: 'root' } },
}, C)).toThrow(/slot "t.rows" is already declared.*"t.host"/)
})
it('throws when one store handle is bound to two scopes', async () => {
const bench = await boot()
bench.erased.register({
name: 'root',
children: {
't.host': { kind: 'single', scope: 'root' },
't.panel': { kind: 'single', scope: 'session' },
},
}, C)
const { handle } = fakeHandle()
bench.erased.register({ name: 't.host', store: handle }, C)
expect(() => bench.erased.register({ name: 't.panel', store: handle }, C))
.toThrow(/one handle, one scope/)
})
it('commits nothing when the core rejects the entry (children stay undeclared)', async () => {
const bench = await boot()
bench.erased.register({ name: 'root' }, C) // 'root' single slot now occupied
expect(() => bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)).toThrow(/already has a registration/)
// The failing call's declaration must not have landed.
expect(() => bench.erased.register({ name: 't.host' }, C)).toThrow(/is not declared/)
})
})
describe('renderer install seam', () => {
it('throws on renderSlot before install (boot-order guidance)', async () => {
const bench = await boot()
expect(() => bench.erased.renderSlot('root', {})).toThrow(/renderer not installed/)
})
it('throws on double install', async () => {
const bench = await boot()
bench.erased.install({ renderRoot: () => null })
expect(() => { bench.erased.install({ renderRoot: () => null }) }).toThrow(/already installed/)
})
it('throws on any non-root key (single ctx-level entry)', async () => {
const bench = await boot()
bench.erased.install({ renderRoot: () => null })
expect(() => bench.erased.renderSlot('t.host', {})).toThrow(/only renders 'root'/)
})
it("throws on renderSlot('root') before any root registration", async () => {
const bench = await boot()
bench.erased.install({ renderRoot: () => null })
expect(() => bench.erased.renderSlot('root', {})).toThrow(/no registration/)
})
it('renders through the installed renderer and returns its product', async () => {
const bench = await boot()
const renderRoot = vi.fn(() => 'tree')
bench.erased.install({ renderRoot })
bench.erased.register({ name: 'root' }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
expect(bench.erased.renderSlot('root', {})).toBe('tree')
expect(renderRoot).toHaveBeenCalledTimes(1)
})
})
describe('host face', () => {
it('serves entriesOf/specOf/isLive off the ledger and flips isLive on disposal', async () => {
const bench = await boot()
const host = captureHost(bench, { 't.host': { kind: 'single', scope: 'root' } })
const dispose = bench.erased.register({ name: 't.host' }, C)
const rootEntry = host.entriesOf('root')[0]
expect(rootEntry).toBeDefined()
expect(rootEntry?.component).toBe(C)
expect(host.specOf('root')).toEqual({ kind: 'single', scope: 'root' })
expect(host.specOf('t.host')).toEqual({ kind: 'single', scope: 'root' })
const childEntry = host.entriesOf('t.host')[0]
expect(host.isLive(childEntry as never)).toBe(true)
dispose()
expect(host.isLive(childEntry as never)).toBe(false)
expect(host.entriesOf('t.host')).toHaveLength(0)
})
it('exposes sessions list/current/cell (current riding the list snapshot)', async () => {
const bench = await boot()
const host = captureHost(bench)
expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
expect(host.sessions.current.getSnapshot()).toBeUndefined()
expect(host.sessions.cell('known')).toMatchObject({ sessionId: 'known' })
expect(host.sessions.cell('ghost')).toBeUndefined()
})
})
describe('store instance axis', () => {
/** Boot with 'root' occupied and the three test children declared. */
async function storeBench() {
const bench = await boot()
const host = captureHost(bench, {
't.host': { kind: 'single', scope: 'root' },
't.rows': { kind: 'list', scope: 'root' },
't.panel': { kind: 'single', scope: 'session' },
})
return { bench, host }
}
it('resolves one instance per (handle x root scope) shared across entries', async () => {
const { bench, host } = await storeBench()
const { handle } = fakeHandle()
bench.erased.register({ name: 't.host', store: handle }, C)
bench.erased.register({ name: 't.rows', id: 'a', store: handle }, C)
const [hostEntry] = host.entriesOf('t.host')
const [rowEntry] = host.entriesOf('t.rows')
const a = host.storeOf(hostEntry as never, undefined)
const b = host.storeOf(rowEntry as never, undefined)
expect(a).toBeDefined()
expect(a).toBe(b) // shared handle, same scope key = same instance
expect(handle.create).toHaveBeenCalledTimes(1)
expect(handle.create).toHaveBeenCalledWith() // root scope: keyless create
})
it('resolves per-session instances keyed by session id, created with the scope key', async () => {
const { bench, host } = await storeBench()
const { handle } = fakeHandle()
bench.erased.register({ name: 't.panel', store: handle }, C)
const [entry] = host.entriesOf('t.panel')
const s1 = host.storeOf(entry as never, 's1')
const s2 = host.storeOf(entry as never, 's2')
expect(s1).not.toBe(s2)
expect(host.storeOf(entry as never, 's1')).toBe(s1) // cached per key
expect(handle.create).toHaveBeenCalledWith('s1')
expect(handle.create).toHaveBeenCalledWith('s2')
expect(() => host.storeOf(entry as never, undefined)).toThrow(/requires a session id/)
})
it('mints a fresh handle per register for the factory (exclusive) form', async () => {
const { bench, host } = await storeBench()
const factory = vi.fn(() => fakeHandle().handle)
bench.erased.register({ name: 't.host', store: factory }, C)
bench.erased.register({ name: 't.rows', id: 'a', store: factory }, C)
expect(factory).toHaveBeenCalledTimes(2)
const a = host.storeOf(host.entriesOf('t.host')[0] as never, undefined)
const b = host.storeOf(host.entriesOf('t.rows')[0] as never, undefined)
expect(a).not.toBe(b) // two mints, two instances
})
it('drops instances with the last holding entry and refuses stale resolution', async () => {
const { bench, host } = await storeBench()
const { handle } = fakeHandle()
const d1 = bench.erased.register({ name: 't.host', store: handle }, C)
bench.erased.register({ name: 't.rows', id: 'a', store: handle }, C)
const rowEntry = host.entriesOf('t.rows')[0]
const hostEntry = host.entriesOf('t.host')[0]
const shared = host.storeOf(rowEntry as never, undefined)
d1() // one holder left: record (and instance) survive
expect(host.storeOf(rowEntry as never, undefined)).toBe(shared)
expect(() => host.storeOf(hostEntry as never, undefined)).not.toThrow() // handle still live via the row entry
// Note: dropping the row entry would sever the last reference; stale
// resolution is covered through the cascade spec below.
})
it('pruneStoreScope clears persisted state per dead session, including never-materialized ones', async () => {
const { bench, host } = await storeBench()
const { handle, created } = fakeHandle()
bench.erased.register({ name: 't.panel', store: handle }, C)
const [entry] = host.entriesOf('t.panel')
const s1 = host.storeOf(entry as never, 's1')
expect(s1).toBe(created[0]) // the resolved instance is the fake the handle minted
bench.svc.pruneStoreScope('s1')
expect(created[0]?.clearPersisted).toHaveBeenCalledTimes(1)
expect(host.storeOf(entry as never, 's1')).not.toBe(s1) // instance dropped, next resolve mints anew
// Never-rendered dead session: a transient instance is created just to clear storage.
const before = created.length
bench.svc.pruneStoreScope('s-never')
expect(created.length).toBe(before + 1)
expect(created[created.length - 1]?.clearPersisted).toHaveBeenCalledTimes(1)
})
})
describe('entry-unload cascade', () => {
it('kills declared children, their contributions, and the ledger rows with the entry', async () => {
const bench = await boot()
let host: SlotRendererHost | undefined
bench.erased.install({
renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
})
bench.ctx.reflect.provide('sessions', fakeSessions())
// The declarer here is NOT the root occupant: root stays occupied by a
// separate entry so disposing the declarer only kills its children.
const disposeRoot = bench.erased.register({ name: 'root' }, C)
bench.erased.renderSlot('root', {})
if (host === undefined) throw new Error('renderer never received the host')
disposeRoot()
const disposeDeclarer = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
bench.erased.register({ name: 't.host' }, C)
const [childEntry] = host.entriesOf('t.host')
expect(childEntry).toBeDefined()
disposeDeclarer()
expect(bench.svc.spec('t.host')).toBeUndefined() // ledger row gone
expect(host.specOf('t.host')).toBeUndefined() // outlets now render empty
expect(bench.svc.entries('t.host')).toHaveLength(0) // contribution cleared
expect(host.isLive(childEntry as never)).toBe(false) // stale bindings will throw upstream
// The freed key is re-declarable by a new entry (no residue).
expect(() => bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)).not.toThrow()
})
it('cascades through cordis fiber disposal (plugin unload = full cleanup)', async () => {
const bench = await boot()
bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
const fiber = bench.ctx.plugin({
name: 'occupant',
inject: ['slots'],
apply: (pluginCtx: Context) => {
pluginCtx.slots.register('t-single', C)
;(pluginCtx.slots as unknown as ErasedService).register({ name: 't.host' }, C)
},
})
await fiber.await()
expect(ctx.slots.entries('t-single')).toHaveLength(1)
expect(bench.svc.entries('t.host')).toHaveLength(1)
await fiber.dispose()
expect(ctx.slots.entries('t-single')).toHaveLength(0)
// The slot definition (registered from root) survives; a new occupant may register.
expect(() => ctx.slots.register('t-single', C)).not.toThrow()
expect(bench.svc.entries('t.host')).toHaveLength(0)
expect(bench.svc.spec('t.host')).toBeDefined() // declarer still live; slot stays declared
})
it('proxies specDynamic/subscribe/getVersion through the core', async () => {
const ctx = await boot()
ctx.slots.define('t-list', { kind: 'list', scope: 'root' })
expect(ctx.slots.specDynamic('t-list')).toEqual({ kind: 'list', scope: 'root' })
expect(ctx.slots.specDynamic('never-defined')).toBeUndefined()
let notified = 0
const unsubscribe = ctx.slots.subscribe('t-list', () => { notified += 1 })
ctx.slots.register('t-list', C, { id: 'row' })
await new Promise(resolve => setTimeout(resolve, 0)) // microtask-batched flush
expect(notified).toBeGreaterThan(0)
expect(ctx.slots.getVersion('t-list')).toBeGreaterThan(0)
unsubscribe()
it('disposer is idempotent (stale second call is a no-op)', async () => {
const bench = await boot()
const dispose = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
dispose()
expect(() => { dispose() }).not.toThrow()
expect(() => bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)).not.toThrow()
})
})
describe('event bridge', () => {
it("re-emits entry writes and child declarations as 'slots/changed'", async () => {
const bench = await boot()
const seen: string[] = []
bench.ctx.on('slots/changed', (key) => { seen.push(key) })
bench.erased.register({
name: 'root', children: { 't.rows': { kind: 'list', scope: 'root' } },
}, C)
bench.erased.register({ name: 't.rows', id: 'a' }, C)
expect(seen).toEqual(['root', 't.rows', 't.rows'])
})
})
+4 -2
View File
@@ -1,8 +1,10 @@
# @deepseek-ai/dsh-client-ui-conversation
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7.
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to both the conversation and details registrations, so the two session slots share one instance per session (selection written by conversation, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, view registry read face, startSession chain).
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
## Model Experience
@@ -2,20 +2,18 @@
* Client plugin body: provide the conversation service and toolview registry,
* register the conversation/details slot occupants and the no-session empty
* state, and mount the chat view with its samples. Assembly only — components
* receive everything through inject factories; nothing here renders directly.
* receive everything through props: the framework standard kit and store
* faces arrive automatically from the declarations below; the inject
* factories contribute the plain-data-and-callbacks business face (design §5).
*/
import { createElement, Fragment, type ReactNode } from 'react'
import type { Context } from 'cordis'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import { scopedSlots, shallowEqual } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
import type {
SessionId, SessionListState, SessionsService, SlotsService,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId, SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
import type { ConvViewProps, SelectionTarget, ViewEntry, ViewId } from './contract/views.ts'
import type { SelectionTarget } from './contract/views.ts'
import type { ConversationInjected, DetailsInjected, EmptyStateInjected } from './contract/slots.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import { ToolViewRegistry } from './toolviews/registry.ts'
import { childSessionScope, registerChat } from './chat/register.ts'
@@ -37,20 +35,13 @@ function need<T>(ctx: Context, name: string): T {
return value
}
/** Per-list-state cwd set (deduped, list order) for the empty-state picker. */
const cwdsCache = new WeakMap<SessionListState, readonly string[]>()
function cwdsOf(state: SessionListState): readonly string[] {
let cached = cwdsCache.get(state)
if (cached === undefined) {
const seen = new Set<string>()
for (const id of state.ids) {
const cwd = state.byId[id]?.cwd
if (cwd !== undefined && cwd !== '') seen.add(cwd)
}
cached = [...seen]
cwdsCache.set(state, cached)
}
return cached
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
const scoped = sessions.scope(id)
if (scoped === undefined) throw new Error(`ui-conversation: session "${id}" resolved no scope`)
const conversation = scoped.get('conversation')
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable through the session scope')
return conversation
}
/**
@@ -80,106 +71,64 @@ export function apply(ctx: Context): void {
() => registerBashSamples(toolviews, childSessionScope(sessions.list)),
'ui-conversation: bash toolview samples')
// ConvViewProps.slots is ScopedSlots<never>: a real outlet with an empty
// whitelist (uncallable by type, correct runtime shape for future grants).
const emptySlots = scopedSlots<never>(slots.core)
// Shared store handle, constructed here so its identity lives and dies with
// this fiber (a module-level handle would be a de-facto singleton). Both
// session-slot registrations declare it; same scope key = same instance, so
// conversation writes and details reads meet in one store.
const chat = createChatStore()
/** conversation slot: skeleton surface assembled once per (entry x session). */
const conversationInject = (b: SessionBinding): ConversationInjected => {
const bctx = b.ctx as Context
const scoped = need<ConversationService>(bctx, 'conversation')
const id = b.sessionId as SessionId
const useSession = b.session.useSelector as UseSession
const selectionStore = scoped.selection
const draftsStore = scoped.drafts
const session = sessions.manager.get(id)
// Watch-driven history pull: assembling the surface IS the watch signal
// (once per entry x session; open() is idempotent and self-recovers).
void session.open()
const viewProps: Omit<ConvViewProps, 'slots'> = {
sessionId: id,
useSession,
useSelection: selectionStore.useSelector,
actions: {
openDetails: (target: SelectionTarget) => { scoped.openDetails(target) },
loadOlder: () => { void session.loadOlder() },
},
}
const injected: ConversationInjected = {
useAncestry: () => sessions.list.useSelector(
() => sessions.ancestry(id),
(a, b) => shallowEqual(a, b)),
views: {
list: () => conversation.views(),
subscribe: fn => conversation.subscribeViews(fn),
version: () => conversation.viewsVersion(),
},
// layout's viewFor value type is its own looser ViewId; the registry is
// the runtime validator (unknown ids fall back to the first view).
useActiveView: () => layout.current.useSelector(s => s.viewFor[id]) as ViewId | undefined,
composer: {
useDraft: () => draftsStore.useSelector(s => s),
setDraft: (text) => { draftsStore.set(text) },
send: (mode) => {
const text = draftsStore.getSnapshot().trim()
if (text === '') return
slots.register({
name: 'conversation',
store: chat,
inject: (sessionId: SessionId, actions: BoundActions<typeof chat>): ConversationInjected => {
const session = sessions.manager.get(sessionId)
const scoped = scopedConversation(sessions, sessionId)
// Watch-driven history pull: assembling the surface IS the watch signal
// (once per entry x session; open() is idempotent and self-recovers).
void session.open()
return {
views: {
list: () => conversation.views(),
subscribe: fn => conversation.subscribeViews(fn),
version: () => conversation.viewsVersion(),
},
send: (text, mode) => {
const trimmed = text.trim()
if (trimmed === '') return
// Optimistic clear with failure restore (choreography lives with the
// sender; the business failure also lands in snapshot.promptError).
draftsStore.set('')
void scoped.send(text, mode).catch(() => {
if (draftsStore.getSnapshot() === '') draftsStore.set(text)
})
// The store write path stays inside the declared actions set:
// restoreDraft itself no-ops once the user typed something new.
actions.clearDraft()
void scoped.send(trimmed, mode).catch(() => { actions.restoreDraft(trimmed) })
},
stop: () => {
scoped.cancel().catch(() => {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
})
},
},
actions: {
openView: (view: ViewId) => { layout.openView(id, view) },
open: (target: SessionId) => { layout.open(target) },
},
renderView: (entry: ViewEntry): ReactNode => {
const children: ReactNode[] = []
if (entry.chrome?.header !== undefined) {
children.push(createElement(entry.chrome.header, { key: 'header', sessionId: id, useSession }))
}
children.push(createElement(entry.component, { key: 'view', ...viewProps, slots: emptySlots }))
if (entry.chrome?.footer !== undefined) {
children.push(createElement(entry.chrome.footer, { key: 'footer', sessionId: id, useSession }))
}
return createElement(Fragment, null, ...children)
},
}
return injected
}
openDetails: (target: SelectionTarget) => {
actions.select(target)
layout.openDetails()
},
loadOlder: () => { void session.loadOlder() },
open: (target: SessionId) => { sessions.open(target) },
}
},
}, ConversationRoot)
/** details slot: minimal selection-driven panel. */
const detailsInject = (b: SessionBinding): DetailsInjected => {
const bctx = b.ctx as Context
const scoped = need<ConversationService>(bctx, 'conversation')
const injected: DetailsInjected = {
useSelection: scoped.selection.useSelector,
actions: { closeDetails: () => { layout.closeDetails() } },
}
return injected
}
slots.register({
name: 'details',
store: chat,
inject: (): DetailsInjected => ({
closeDetails: () => { layout.closeDetails() },
}),
}, DetailsPanel)
/** conversation.empty root slot: the NEW SESSION hero. */
const emptyInject = (): EmptyStateInjected => {
const useCwds: SnapshotSelectorHook<readonly string[]> = (sel, eq) =>
sessions.list.useSelector(s => sel(cwdsOf(s)), eq)
const injected: EmptyStateInjected = {
useCwds,
actions: { startSession: opts => conversation.startSession(opts) },
}
return injected
}
slots.register('conversation', ConversationRoot, { inject: conversationInject })
slots.register('details', DetailsPanel, { inject: detailsInject })
slots.register('conversation.empty', EmptyState, { inject: emptyInject })
slots.register({
name: 'conversation.empty',
inject: (): EmptyStateInjected => ({
startSession: opts => conversation.startSession(opts),
}),
}, EmptyState)
}
@@ -122,7 +122,7 @@ function StreamingTail({ useSession, onGrow }: {
export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
const { toolviews, t } = deps
return function ChatView({ sessionId, useSession: useSessionWide, useSelection, actions }: ConvViewProps) {
return function ChatView({ sessionId, useSession: useSessionWide, useStore, actions }: ConvViewProps) {
const useSession = useSessionWide as UseConversation
const nodes = useSession((s) => s.nodes)
const runningCalls = useSession((s) => s.runningCalls)
@@ -131,7 +131,7 @@ export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}${s.openError.code}`)
const hasMore = useSession((s) => s.hasMore)
const loadingOlder = useSession((s) => s.loadingOlder)
const selectedCallId = useSelection((sel) => sel?.callId)
const selectedCallId = useStore((s) => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
@@ -1,13 +1,12 @@
// ToolViewOutlet: resolves the toolview for one call through ctx.toolviews
// (uSES over the registry version so unload falls back live) and renders it
// behind a per-row error boundary. GenericToolCard is the render-side
// fallback for both a registry miss and a crashed custom row. A registrant
// inject factory is called once per (registration x binding) and cached,
// mirroring the scoped-slots injection discipline.
// fallback for both a registry miss and a crashed custom row. Pure props
// machinery, zero React context: a registrant inject factory receives the
// sessionId this outlet already holds, is called once per (registration x
// session) and cached, mirroring the slot injection discipline.
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import { useSessionBinding } from '@deepseek-ai/dsh-client-web-react'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import { Component, useSyncExternalStore, type ReactNode } from 'react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolViewInject, ToolViewProps, ToolViewResolver } from '../contract/toolview.ts'
import { GenericToolCard } from './GenericToolCard.tsx'
@@ -19,19 +18,21 @@ export interface ToolViewOutletProps {
viewProps: ToolViewProps
}
/** Inject cache: per inject-factory (stable per registration) x binding object. */
const injectCache = new WeakMap<ToolViewInject<object>, WeakMap<object, object>>()
/** Inject cache: per inject-factory (stable per registration) x session id.
* The inner Map lives and dies with its factory (WeakMap entry), so entries
* are bounded by the session count over the registration's lifetime. */
const injectCache = new WeakMap<ToolViewInject<object>, Map<SessionId, object>>()
function cachedInject(inject: ToolViewInject<object>, binding: SessionBinding): object {
let perBinding = injectCache.get(inject)
if (!perBinding) {
perBinding = new WeakMap()
injectCache.set(inject, perBinding)
function cachedInject(inject: ToolViewInject<object>, sessionId: SessionId): object {
let perSession = injectCache.get(inject)
if (!perSession) {
perSession = new Map()
injectCache.set(inject, perSession)
}
let props = perBinding.get(binding)
let props = perSession.get(sessionId)
if (!props) {
props = inject(binding)
perBinding.set(binding, props)
props = inject(sessionId)
perSession.set(sessionId, props)
}
return props
}
@@ -61,16 +62,6 @@ class RowErrorBoundary extends Component<
}
}
/** Split component: only inject-carrying registrations need the session
* binding hook (keeps injectless rendering free of the Provider requirement). */
function InjectedRow({ Row, inject, viewProps }: {
Row: FC<ToolViewProps & object>; inject: ToolViewInject<object>; viewProps: ToolViewProps
}) {
const binding = useSessionBinding()
const injected = cachedInject(inject, binding)
return <Row {...{ ...injected, ...viewProps }} />
}
export function ToolViewOutlet({ registry, sessionId, toolName, viewProps }: ToolViewOutletProps) {
const version = useSyncExternalStore(
(fn) => registry.subscribe(fn),
@@ -83,7 +74,7 @@ export function ToolViewOutlet({ registry, sessionId, toolName, viewProps }: Too
<RowErrorBoundary resetKey={version} fallback={<GenericToolCard {...viewProps} />}>
{resolved.inject === undefined
? <Row {...viewProps} />
: <InjectedRow Row={Row} inject={resolved.inject} viewProps={viewProps} />}
: <Row {...{ ...cachedInject(resolved.inject, sessionId), ...viewProps }} />}
</RowErrorBoundary>
)
}
@@ -1,63 +1,67 @@
/**
* Slot-ring contract for the conversation package: the composed props shapes
* its registrants mount into the layout-owned slots (conversation / details /
* conversation.empty — the SlotMap declarations live with ui-layout, the
* slot owner). Per the share-ownership rule, the owner share is REFERENCED
* from ui-layout and each registrant's injected share is declared here, next
* to the component that receives it; full component props = owner share &
* standard share & own injected share.
* conversation.empty). Terminal slot design (§3): full component props are the
* automatic shares — PropsRuntime<K> (framework standard kit) & PropsStore<H>
* (declared store's read/write faces) & the injected business face declared
* here. No renderSlot share: none of the three registrations declares
* children, so the zero-renderSlot inference applies.
*/
import type { ReactNode } from 'react'
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConvOwnerProps, DetailsOwnerProps, EmptyOwnerProps } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SelectionTarget, ViewEntry, ViewId } from './views.ts'
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { createChatStore } from '../stores.ts'
import type { SelectionTarget, ViewEntry } from './views.ts'
/** Injected share of the conversation slot (assembled by apply's inject factory). */
/** The shared chat store handle type (apply constructs one; conversation and details both declare it). */
export type ChatStore = ReturnType<typeof createChatStore>
/**
* Injected share of the conversation slot: plain data and callbacks only
* (design §5 — hooks are framework-made). The store lines that used to ride
* here live in the declared {@link ChatStore} now; ancestry derives from the
* standard useSessions hook in-component; view rendering moved into the
* component, which holds every share a view needs.
*/
export interface ConversationInjected {
/** Breadcrumb chain (root ancestor first, self last; ancestry(list) feed). */
useAncestry: () => readonly SessionSummary[]
/** View registry read face (uSES triple from the conversation service). */
views: {
list(): readonly ViewEntry[]
subscribe(fn: () => void): () => void
version(): number
}
/** Active view accessor (layout.viewFor backed; undefined falls to 'chat'). */
useActiveView: () => ViewId | undefined
/** Composer surface: draft store hook pair + send/stop choreography. */
composer: {
useDraft: () => string
setDraft(text: string): void
send(mode: 'queue' | 'steer'): void
stop(): void
}
actions: {
openView(view: ViewId): void
open(id: SessionId): void
}
/** Renders the active view's body (the owner closes over ConvViewProps assembly). */
renderView: (entry: ViewEntry) => ReactNode
/** Send choreography: trims, clears the draft optimistically, restores it on failure. */
send(text: string, mode: 'queue' | 'steer'): void
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
stop(): void
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
openDetails(target: SelectionTarget): void
/** Pull one older history page. */
loadOlder(): void
/** Navigate to another session (breadcrumb ancestors). */
open(id: SessionId): void
}
/** Full conversation-slot component props: owner share & standard share & injected share. */
export type ConversationSlotProps = ConvOwnerProps & { useSession: UseSession } & ConversationInjected
/** Full conversation-slot component props: runtime share & store share & injected share. */
export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsStore<ChatStore> & ConversationInjected
/** Injected share of the details slot. */
/**
* Injected share of the details slot: the panel is otherwise a pure reader of
* the shared chat store, but its close button is a layout orchestration call.
*/
export interface DetailsInjected {
useSelection: SnapshotSelectorHook<SelectionTarget | null>
actions: { closeDetails(): void }
/** Close the details panel (layout geometry stays with ctx.layout). */
closeDetails(): void
}
/** Full details-slot component props. */
export type DetailsSlotProps = DetailsOwnerProps & { useSession: UseSession } & DetailsInjected
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected
/** Injected share of the no-session empty-state slot (root slot: no standard share). */
/** Injected share of the no-session empty-state slot. */
export interface EmptyStateInjected {
/** cwd options derived from sessions.list (deduped; assembled by the inject factory). */
useCwds: SnapshotSelectorHook<readonly string[]>
actions: { startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void> }
/** The create → navigate → first-send chain, in one service call. */
startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void>
}
/** Full empty-state component props. */
export type EmptyStateSlotProps = EmptyOwnerProps & EmptyStateInjected
/** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */
export type EmptyStateSlotProps = PropsRuntime<'conversation.empty'> & EmptyStateInjected
@@ -6,7 +6,6 @@
* implementation files import this, never each other.
*/
import type { FC } from 'react'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { CallId, Translate } from './views.ts'
@@ -28,11 +27,13 @@ export interface ToolViewProps {
/**
* Toolview inject factory: produces the registrant's private injected share
* `I`, called once per (registration x session binding) and cached by the
* render outlet. Session-bound by nature — tool rows always render inside a
* session subtree.
* `I`, called once per (registration x session) and cached by the render
* outlet. Mirrors the slot inject shape (parameters derive from the
* declaration): toolviews are session-domain by nature, so the factory
* receives the session id only — service access goes through the
* registrant's own apply-closure ctx (design §5; binding objects retired).
*/
export type ToolViewInject<I extends object> = (b: SessionBinding) => I
export type ToolViewInject<I extends object> = (sessionId: SessionId) => I
/** Options accepted by the toolview registry's register; `I` is inferred from the inject factory. */
export interface ToolViewOptions<I extends object = object> {
@@ -1,11 +1,11 @@
/**
* View-ring contract: the typed conversation view table and the props
* surfaces handed to registered views. Shared face between the skeleton
* domain (ConversationRoot renders views) and the chat domain (registers the
* chat view); domain implementation files import this, never each other.
* View-ring contract: the typed conversation view table, the chat store state
* shared through it, and the props surfaces handed to registered views.
* Shared face between the skeleton domain (ConversationRoot renders views)
* and the chat domain (registers the chat view); domain implementation files
* import this, never each other.
*/
import type { FC } from 'react'
import type { ScopedSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
@@ -57,12 +57,33 @@ export interface ChromeProps { sessionId: SessionId; useSession: UseSession }
/** Selection target for the details linkage channel (toolcall is the step special case). */
export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: CallId; toolName?: string }
/** Props handed to registered conversation views. */
/**
* Chat store state (slot terminal design §4): the per-session store shared by
* the conversation and details registrations. `createChatStore` implements
* this shape; views read it through {@link ConvViewProps}'s pass-through hook.
* `view` may carry a stale persisted id after a view plugin unloads — the
* registry is the runtime validator (unknown ids fall back to the first view).
*/
export interface ChatStoreState {
/** Details-linkage channel (conversation writes, details reads). */
selection: SelectionTarget | null
/** Composer draft (persisted; survives session switches and reloads). */
draft: string
/** Active conversation view id; null falls back to the first registered view. */
view: ViewId | null
}
/**
* Props handed to registered conversation views. `useSession` and `useStore`
* are the framework hooks ConversationRoot received as a slot registrant,
* passed through unchanged (hook transfer is plain props passing; no
* business-made subscription exists on this path). No renderSlot share: the
* view ring delegates no sub-slots.
*/
export interface ConvViewProps {
sessionId: SessionId
useSession: UseSession
useSelection: SnapshotSelectorHook<SelectionTarget | null>
/** Chat store read face (selection is the only slice views consume today). */
useStore: SnapshotSelectorHook<ChatStoreState>
actions: { openDetails(t: SelectionTarget): void; loadOlder(): void }
/** Chat has no delegated sub-slots in P-I (toolviews go through the named registry). */
slots: ScopedSlots<never>
}
@@ -14,14 +14,14 @@ export { ConversationService } from './service.ts'
export { ToolViewRegistry } from './toolviews/registry.ts'
export type {
CallId, ChromeProps, ChromePropsOf, ConversationViewMap, ConvViewProps, ConvViewPropsOf,
SelectionTarget, Translate, ViewEntry, ViewEntryDef, ViewId,
CallId, ChatStoreState, ChromeProps, ChromePropsOf, ConversationViewMap, ConvViewProps,
ConvViewPropsOf, SelectionTarget, Translate, ViewEntry, ViewEntryDef, ViewId,
} from './contract/views.ts'
export type {
ResolvedToolView, ToolCallBlock, ToolViewOptions, ToolViewProps, ToolViewResolver,
} from './contract/toolview.ts'
export type {
ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps,
ChatStore, ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.
@@ -1,8 +1,10 @@
/**
* ConversationService implementation: scope-addressed send/cancel, per-scope
* selection/draft stores booked on the session scope fiber, view registry
* with a uSES read face, openDetails orchestration, and the empty-state
* startSession chain. Contract: api-contracts v3 section 7.
* ConversationService implementation: scope-addressed send/cancel, view
* registry with a uSES read face, and the empty-state startSession chain.
* Contract: api-contracts v3 section 7. Selection/draft state moved to the
* declared chat store (slot terminal design §4) — the per-scope store maps,
* lazy construction, and prune bookkeeping this service used to carry are
* retired; what remains is the send/stop orchestration face.
*
* Scope addressing rides the cordis Service tracker: property access through
* `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods
@@ -20,11 +22,8 @@ import type { Context } from 'cordis'
// SessionsService tags contexts with — scopeOf then always returns undefined
// in the browser while unit tests (single-instance path resolution) stay green.
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SelectionTarget, ViewEntry, ViewId } from './index.ts'
import type { Session, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ViewEntry, ViewId } from './index.ts'
/** Mutable view-registry cell (plain object: mutation never crosses the tracker proxy). */
interface ViewsState {
@@ -37,8 +36,6 @@ interface ViewsState {
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
export class ConversationService extends Service {
private readonly selections = new Map<SessionId, SnapshotStore<SelectionTarget | null>>()
private readonly draftStores = new Map<SessionId, SnapshotStore<string>>()
private readonly viewsState: ViewsState = {
entries: new Map(), cache: null, tick: 0, listeners: new Set(),
}
@@ -71,37 +68,6 @@ export class ConversationService extends Service {
if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`)
}
/** Per-scope selection channel (details linkage); root access throws. */
get selection(): SnapshotStore<SelectionTarget | null> {
return this.scopeStore(this.selections, 'selection',
() => createSnapshotStore<SelectionTarget | null>(null))
}
/**
* Per-scope draft store, persisted per session id; root access throws.
* Persistence is hand-rolled (raw string per key): the snapshot-store
* engine's persist middleware object-spreads state on save, corrupting
* primitive-state stores.
*/
get drafts(): SnapshotStore<string> {
return this.scopeStore(this.draftStores, 'drafts', (id) => {
const key = `dsh.conversation.draft.${id}`
const store = createSnapshotStore<string>(loadDraft(key))
store.subscribe(() => { saveDraft(key, store.getSnapshot()) })
return store
})
}
/**
* Write the scoped selection and open the details panel. Orchestration
* only — panel geometry stays with ctx.layout.
* @param target - selection target.
*/
openDetails(target: SelectionTarget): void {
this.selection.set(target)
this.requireLayout().openDetails()
}
/**
* Register a conversation view. Duplicate ids throw; the registration is an
* effect on the caller's fiber (plugin unload collects it).
@@ -170,9 +136,9 @@ export class ConversationService extends Service {
const sessions = this.requireSessions()
const id = await sessions.create(opts.cwd === undefined ? {} : { cwd: opts.cwd })
// The manager notifier flushes per microtask; one await guarantees the
// list-store projection landed before layout.open validates against it.
// list-store projection landed before sessions.open validates against it.
await Promise.resolve()
this.requireLayout().open(id)
sessions.open(id)
const scoped = sessions.scope(id)
if (scoped === undefined) throw new Error(`conversation.startSession: created session "${id}" resolved no scope`)
// ctx.get, not scoped.conversation: property access walks the fiber
@@ -185,34 +151,11 @@ export class ConversationService extends Service {
/** Resolve the caller scope's Session or throw on root contexts. */
private scopedSession(op: string): Session {
const id = this.scopeId(op)
return this.requireSessions().manager.get(id)
}
/** Read the caller's session scope tag; root contexts fail loud. */
private scopeId(op: string): SessionId {
const id = scopeOf(this.ctx)
if (id === undefined) {
throw new Error(`conversation.${op} requires a session scope — address one via ctx.sessions.scope(id).conversation`)
}
return id
}
/**
* Per-scope store account: lazily created, booked on the scope fiber so the
* scope teardown (SessionsService prune) collects the entry.
*/
private scopeStore<T>(
map: Map<SessionId, SnapshotStore<T>>, op: string,
make: (id: SessionId) => SnapshotStore<T>): SnapshotStore<T> {
const id = this.scopeId(op)
let store = map.get(id)
if (store === undefined) {
store = make(id)
map.set(id, store)
this.ctx.effect(() => () => { map.delete(id) }, `conversation.${op} scope account`)
}
return store
return this.requireSessions().manager.get(id)
}
private requireSessions(): SessionsService {
@@ -223,12 +166,6 @@ export class ConversationService extends Service {
if (sessions === undefined) throw new Error('conversation: sessions service unavailable')
return sessions
}
private requireLayout(): LayoutService {
const layout = this.ctx.get('layout')
if (layout === undefined) throw new Error('conversation: layout service unavailable')
return layout
}
}
function bumpViews(state: ViewsState): void {
@@ -236,16 +173,3 @@ function bumpViews(state: ViewsState): void {
state.tick += 1
for (const fn of [...state.listeners]) fn()
}
function loadDraft(key: string): string {
/* v8 ignore next -- storage-less environment guard (workers/tests without DOM); jsdom always provides localStorage. */
if (typeof localStorage === 'undefined') return ''
return localStorage.getItem(key) ?? ''
}
function saveDraft(key: string, text: string): void {
/* v8 ignore next -- storage-less environment guard (workers/tests without DOM); jsdom always provides localStorage. */
if (typeof localStorage === 'undefined') return
if (text === '') localStorage.removeItem(key)
else localStorage.setItem(key, text)
}
@@ -1,43 +1,82 @@
// ConversationRoot: the conversation slot's skeleton (figma Header 39:27730 +
// Tab_Group + view area + composer). Zero framework imports — everything
// arrives via props from the inject factory: breadcrumb feed, view registry
// read face, per-view render, and the composer's draft/send choreography.
// The active view id lives in layout.viewFor (shell viewing state), read and
// written through injected accessors.
// Tab_Group + view area + composer). Pure component — everything arrives via
// props: the framework standard kit (useSession/sessionId/useSessions), the
// declared chat store's useStore/actions, and the injected business face.
// Breadcrumbs derive from useSessions with a pure parentId walk; the active
// view id lives in the chat store's `view` field (per-session by store scope).
import { useSyncExternalStore } from 'react'
import { useMemo, useSyncExternalStore, type ReactNode } from 'react'
import clsx from 'clsx'
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'
import type { InputBarError } from './InputBar.tsx'
import css from './ConversationRoot.module.css'
/**
* Full props = owner share (sessionId) & standard share (useSession) &
* injected share — composed by reference from the contract, never re-typed
* here (share-ownership rule).
*/
/** Full props = the automatic shares & injected share — composed by reference
* from the contract, never re-typed here (share-ownership rule). */
export type ConversationRootProps = ConversationSlotProps
/** Breadcrumb chain: walk parentId links (root ancestor first, self last;
* empty when unknown; a broken link stops the walk). Pure twin of the
* sessions service's ancestry — components derive, they don't subscribe. */
function deriveAncestry(list: SessionListState, id: SessionId): readonly SessionSummary[] {
const chain: SessionSummary[] = []
let cursor: SessionId | undefined = id
while (cursor !== undefined) {
const summary: SessionSummary | undefined = list.byId[cursor]
if (summary === undefined || chain.includes(summary)) break
chain.unshift(summary)
cursor = summary.parentId
}
return chain
}
export function ConversationRoot({
sessionId, useSession, useAncestry, views, useActiveView, composer, actions, renderView,
sessionId, useSession, useSessions, useStore, actions,
views, send, stop, openDetails, loadOlder, open,
}: ConversationRootProps) {
useSyncExternalStore(views.subscribe, views.version)
const list = views.list()
const activeId = useActiveView() ?? 'chat'
// The store's persisted view id may be stale (view plugin unloaded); the
// registry is the runtime validator — unknown ids fall to the first view.
const activeId = useStore(s => s.view) ?? 'chat'
const active = list.find(v => v.id === activeId) ?? list[0]
const ancestry = useAncestry()
const draft = composer.useDraft()
const running = useSession(s => (s as { running: boolean }).running)
const removed = useSession(s => (s as { removed: boolean }).removed)
const promptError = useSession(s => (s as { promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null }).promptError)
const turns = useSession(s => countTurns(s as { nodes: readonly { kind: string }[] }))
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
const draft = useStore(s => s.draft)
const running = useSession(s => s.running)
const removed = useSession(s => s.removed)
const promptError = useSession(s => s.promptError)
const turns = useSession(s => countTurns(s))
const error: InputBarError | null = promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message}${promptError.error.code}` }
// Views receive the shares this component already holds (hook transfer is
// plain props passing); the callback slice is referentially stable per
// injected identity so memoized view rows hold.
const viewProps = useMemo<ConvViewProps>(() => ({
sessionId, useSession, useStore,
actions: { openDetails, loadOlder },
}), [sessionId, useSession, useStore, openDetails, loadOlder])
const renderView = (entry: ViewEntry): ReactNode => {
const Header = entry.chrome?.header
const Footer = entry.chrome?.footer
const View = entry.component
return (
<>
{Header !== undefined && <Header sessionId={sessionId} useSession={useSession} />}
<View {...viewProps} />
{Footer !== undefined && <Footer sessionId={sessionId} useSession={useSession} />}
</>
)
}
return (
<div className={css.root}>
<header className={css.header}>
@@ -52,7 +91,7 @@ export function ConversationRoot({
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { actions.open(s.id) }}
onClick={() => { open(s.id) }}
>
{s.title}
</button>
@@ -74,7 +113,7 @@ export function ConversationRoot({
role="tab"
aria-selected={v.id === active?.id}
className={clsx(css.tab, v.id === active?.id && css.tabActive)}
onClick={() => { actions.openView(v.id) }}
onClick={() => { actions.setView(v.id) }}
>
{v.label}
</button>
@@ -93,9 +132,9 @@ export function ConversationRoot({
disabled={removed}
error={error}
variant="composer"
onDraftChange={composer.setDraft}
onSend={composer.send}
onStop={composer.stop}
onDraftChange={actions.setDraft}
onSend={(mode) => { send(draft, mode) }}
onStop={stop}
/>
</div>
)
@@ -1,14 +1,16 @@
// DetailsPanel, P-I minimal form: close button + the selected call's args and
// result rendered raw. The three-段 Switch / Prev-Next stepping / See-in-
// trajectory are deferred (ledger). Subscribes to the per-scope selection and
// derives the call material from the session snapshot — no data of its own.
// trajectory are deferred (ledger). Reads the selection from the shared chat
// store (conversation writes, this panel reads — the cross-registration
// share the store seat exists for) and derives the call material from the
// session snapshot — no data of its own.
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'
/** Full props composed by reference from the contract (owner & standard & injected shares). */
/** Full props composed by reference from the contract (automatic shares & injected share). */
export type DetailsPanelProps = DetailsSlotProps
/** Selected call material: resolved result node, or the in-flight running call's args. */
@@ -41,13 +43,13 @@ function pretty(raw: string): string {
}
}
export function DetailsPanel({ useSession, useSelection, actions }: DetailsPanelProps) {
const selection = useSelection(s => s)
export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPanelProps) {
const selection = useStore(s => s.selection)
const callId = selection?.callId
// materialFor builds a fresh wrapper; shallowEqual short-circuits on its
// stable members (result node reference rides the snapshot's structural sharing).
const material = useSession(
s => (callId === undefined ? null : materialFor(s as ConversationSnapshot, callId)),
s => (callId === undefined ? null : materialFor(s, callId)),
(a, b) => shallowEqual(a, b))
return (
@@ -58,7 +60,7 @@ export function DetailsPanel({ useSession, useSelection, actions }: DetailsPanel
</div>
<button
type="button" className={css.close} aria-label="关闭详情"
onClick={() => { actions.closeDetails() }}
onClick={() => { closeDetails() }}
>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
@@ -1,12 +1,14 @@
// EmptyState (figma NEW SESSION screen): centered hero card built around the
// SAME InputBar component the resident composer uses (the empty→content
// transition is one component changing position, never a swap). Project
// picker: cwd set derived from sessions.list plus a free-form new-directory
// input; submit runs the startSession chain (create → open → send) in one
// service call.
// picker: cwd set derived in-component from the standard useSessions hook
// (subscription is the framework's, derivation is a pure function — design
// §6) plus a free-form new-directory input; submit runs the startSession
// chain (create → open → send) in one service call.
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { EmptyStateSlotProps } from '../contract/slots.ts'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
@@ -15,11 +17,22 @@ import css from './EmptyState.module.css'
/** Select sentinel for the free-form directory entry (impossible as a real path: not absolute). */
const NEW_DIR = '::new-directory'
/** Full props composed by reference from the contract (owner & injected shares; root slot has no standard share). */
/** Full props composed by reference from the contract (runtime share & injected share; no store). */
export type EmptyStateProps = EmptyStateSlotProps
export function EmptyState({ useCwds, actions }: EmptyStateProps) {
const cwds = useCwds(s => s)
/** Deduped cwd set in list order (pure derivation over the sessions list). */
function deriveCwds(state: SessionListState): readonly string[] {
const seen = new Set<string>()
for (const id of state.ids) {
const cwd = state.byId[id]?.cwd
if (cwd !== undefined && cwd !== '') seen.add(cwd)
}
return [...seen]
}
export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
const list = useSessions(s => s)
const cwds = useMemo(() => deriveCwds(list), [list])
// Local viewing state: the empty state owns no session, so its draft is
// ephemeral by design (drafts are keyed by session id; there is none yet).
const [draft, setDraft] = useState('')
@@ -35,14 +48,14 @@ export function EmptyState({ useCwds, actions }: EmptyStateProps) {
setSending(true)
setError(null)
const chosen = cwd.trim()
actions.startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) })
startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) })
.catch((reason: unknown) => {
// The empty state survives failure with the draft intact (no session
// exists to carry promptError; this is the only local error surface).
setError({ op: 'send', message: reason instanceof Error ? reason.message : String(reason) })
setSending(false)
})
// Success needs no cleanup: layout.open swaps this slot out for the session body.
// Success needs no cleanup: the session selection swaps this slot out for the session body.
}
const picker = (
@@ -0,0 +1,40 @@
/**
* Chat store factory (slot terminal design §4): selection + draft + active
* view for one session, shared by the conversation and details registrations
* (apply constructs one handle and passes it to both). Session-scope
* derivation: both mount slots are scope=session, so the framework creates
* one instance per session; the persist key is scope-suffixed by the
* framework, aligning with the previous per-session draft persistence.
*
* 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 type { ChatStoreState, SelectionTarget, ViewId } from './contract/views.ts'
/**
* Declare the per-session chat store. `selection` is the details-linkage
* channel (conversation writes, details reads); `draft` is the composer text
* (persisted so it survives session switches and reloads); `view` is the
* active conversation view id (previously layout.viewFor — store seat is the
* cross-remount survival channel, null falls back to the first registered view).
* @returns the store handle (spec + identity + factory in one value).
*/
export function createChatStore() {
return defineStore({
// Anchored to the contract shape: views consume the store through
// ConvViewProps' SnapshotSelectorHook<ChatStoreState>, so init and the
// contract cannot drift.
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
persist: 'dsh.conversation.chat',
actions: {
select: (d, target: SelectionTarget | null) => { d.selection = target },
setDraft: (d, text: string) => { d.draft = text },
clearDraft: (d) => { d.draft = '' },
// Optimistic-send failure restore: only when the user typed nothing new
// since the clear (send choreography lives in the inject factory).
restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text },
setView: (d, view: ViewId) => { d.view = view },
},
})
}
@@ -1,26 +1,32 @@
// @vitest-environment jsdom
// apply inject factories exercised end to end: the conversation slot surface
// (ancestry feed, views triple, active view, composer choreography incl.
// optimistic clear + failure restore, renderView chrome assembly, watch-driven
// open), the details surface, and the empty-state surface (cwd derivation
// cache). Complements chat-apply.spec.tsx, which stops at registration.
// apply inject factories exercised end to end against the terminal thin
// shape: the conversation surface (views triple, send choreography incl.
// optimistic clear + failure restore THROUGH the declared store actions,
// openDetails = select action + layout orchestration, watch-driven open,
// sessions.open navigation), the injectless-but-closeDetails details surface,
// and the one-callback empty surface. Complements chat-apply.spec.tsx
// (registration) and selection-survival.spec.ts (store axis).
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { createElement } from 'react'
import { createSnapshotStore, bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { cleanup } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/src/store/index.ts'
import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, SessionId, SessionListState } 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 { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationService, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type {
ConversationInjected, DetailsInjected, EmptyStateInjected,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { createChatStore } from '../src/client/stores.ts'
afterEach(cleanup)
const ROOT = 'root-1' as SessionId
type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
type ChatActions = ChatInstance['actions']
const SCOPE_TAG: symbol = (() => {
const recorded: (string | symbol)[] = []
const spy = new Proxy(new Context(), {
@@ -35,28 +41,18 @@ const SCOPE_TAG: symbol = (() => {
return symbol
})()
function snapshotBase(): ConversationSnapshot {
return {
sessionId: ROOT, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
}
async function bench() {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const slots = ctx.get('slots') as SlotsService
const listStore = createSnapshotStore<SessionListState>({
ids: [ROOT],
byId: { [ROOT]: { id: ROOT, title: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
})
const snap = snapshotBase()
current: ROOT,
} as SessionListState)
const sessionFake = {
getSnapshot: () => snap,
subscribe: () => () => {},
useSelector: undefined as unknown,
open: vi.fn(() => Promise.resolve()),
loadOlder: vi.fn(() => Promise.resolve()),
prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
@@ -64,7 +60,6 @@ async function bench() {
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { accepted: true } })),
}
sessionFake.useSelector = bindSnapshotSelector(sessionFake as never)
const scopes = new Map<SessionId, Context>()
const mint = (id: SessionId): Context => {
let scoped = scopes.get(id)
@@ -77,200 +72,143 @@ async function bench() {
const sessionsFake = {
list: listStore,
manager: { get: () => sessionFake },
ancestry: (id: SessionId) => {
const s = listStore.getSnapshot().byId[id]
return s === undefined ? [] : [s]
},
scope: (id: SessionId) => mint(id),
cell: () => undefined,
create: vi.fn(() => Promise.resolve(ROOT)),
open: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
const layoutFake = {
current: createSnapshotStore<{ sessionId?: SessionId; viewFor: Record<string, string> }>({ viewFor: {} }),
open: vi.fn(), openView: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(),
}
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
ctx.provide('layout', layoutFake)
ctx.provide('i18n', { bind: () => (key: string) => key })
const slots = ctx.get('slots') as SlotsService
slots.define('conversation', { kind: 'single', scope: 'session' })
slots.define('details', { kind: 'single', scope: 'session' })
slots.define('conversation.empty', { kind: 'single', scope: 'root' })
// The AppFrame role: the three conversation-package slots must be declared
// by a live entry before apply can contribute into them (the stand-in
// consumes renderSlot to satisfy the declare-means-render check).
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, (_p: { renderSlot?: unknown }) => null)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const binding: SessionBinding = {
sessionId: ROOT as never,
session: { useSelector: sessionFake.useSelector } as never,
ctx: mint(ROOT) as never,
// Reach the render-side entry view (inject + store handle) the way the
// renderer does: through the host face.
let host: SlotRendererHost | undefined
slots.install({ renderRoot: (h) => { host = h; return null } })
slots.renderSlot('root', {})
const hostFace = host!
const entryOf = (key: 'conversation' | 'details' | 'conversation.empty') => hostFace.entriesOf(key)[0]!
/** Resolve store instance + call the inject the way the outlet would. */
const conversationSurface = (id: SessionId) => {
const entry = entryOf('conversation')
const instance = hostFace.storeOf(entry, id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationInjected)(
id, instance.actions)
return { instance, injected }
}
const entryOf = (key: 'conversation' | 'details' | 'conversation.empty') => {
const entries = slots.entries(key)
return entries[0]! as { options: { inject: (b: unknown) => Record<string, unknown> } }
}
return { ctx, slots, binding, sessionFake, sessionsFake, layoutFake, mint, entryOf }
return { ctx, slots, hostFace, entryOf, conversationSurface, sessionFake, sessionsFake, layoutFake, mint }
}
describe('conversation slot inject surface', () => {
it('assembles the full surface and pulls history through the watch signal', async () => {
it('assembles the thin surface, pulls history through the watch signal, navigates via sessions.open', async () => {
const b = await bench()
const injected = b.entryOf('conversation').options.inject(b.binding) as {
useAncestry: () => readonly { id: SessionId }[]
views: { list(): readonly ViewEntry[]; version(): number; subscribe(fn: () => void): () => void }
useActiveView: () => string | undefined
composer: { useDraft: () => string; setDraft(t: string): void; send(m: string): void; stop(): void }
actions: { openView(v: string): void; open(id: SessionId): void }
renderView: (entry: ViewEntry) => unknown
}
const { injected } = b.conversationSurface(ROOT)
expect(b.sessionFake.open).toHaveBeenCalledTimes(1)
expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
injected.actions.openView('chat')
expect(b.layoutFake.openView).toHaveBeenCalledWith(ROOT, 'chat')
injected.actions.open(ROOT)
expect(b.layoutFake.open).toHaveBeenCalledWith(ROOT)
injected.open(ROOT)
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
injected.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
})
it('composer send trims, optimistically clears, and restores on failure; stop swallows rejection', async () => {
it('send trims, optimistically clears through actions, restores on failure without clobbering new typing', async () => {
const b = await bench()
const injected = b.entryOf('conversation').options.inject(b.binding) as {
composer: { setDraft(t: string): void; send(m: 'queue'): void; stop(): void }
}
const scoped = b.mint(ROOT).get('conversation') as ConversationService
// Whitespace-only draft: no send.
scoped.drafts.set(' ')
injected.composer.send('queue')
const { instance, injected } = b.conversationSurface(ROOT)
// Whitespace-only: no send, and the (whitespace) draft is not cleared.
instance.actions.setDraft(' ')
injected.send(' ', 'queue')
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
expect(instance.store.getSnapshot().draft).toBe(' ')
// Success: cleared and stays cleared.
injected.composer.setDraft('hello')
injected.composer.send('queue')
expect(scoped.drafts.getSnapshot()).toBe('')
instance.actions.setDraft('hello')
injected.send('hello', 'queue')
expect(instance.store.getSnapshot().draft).toBe('')
await Promise.resolve()
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
// Failure: restored (draft still empty when the rejection lands).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
injected.composer.setDraft('retry me')
injected.composer.send('queue')
instance.actions.setDraft('retry me')
injected.send('retry me', 'queue')
await vi.waitFor(() => {
expect(scoped.drafts.getSnapshot()).toBe('retry me')
expect(instance.store.getSnapshot().draft).toBe('retry me')
})
// Failure with new typing: no clobber.
// Failure landing after new typing: no clobber (restoreDraft fills empty only).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
injected.composer.send('queue')
injected.composer.setDraft('typed during flight')
injected.send('retry me', 'queue')
instance.actions.setDraft('typed during flight')
await new Promise(r => setTimeout(r, 0))
expect(scoped.drafts.getSnapshot()).toBe('typed during flight')
expect(instance.store.getSnapshot().draft).toBe('typed during flight')
// Stop failure is swallowed (promptError owns the surface).
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
injected.composer.stop()
injected.stop()
await new Promise(r => setTimeout(r, 0))
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
})
it('view actions forward: openDetails writes selection through the scoped service, loadOlder hits the session', async () => {
it('openDetails writes the selection through the store actions and opens the panel', async () => {
const b = await bench()
const injected = b.entryOf('conversation').options.inject(b.binding) as {
// viewProps rides renderView's closure; reach the actions through a rendered entry.
renderView: (entry: ViewEntry) => React.ReactNode
}
let captured: { openDetails(t: { turnSeq: number; callId?: string }): void; loadOlder(): void } | undefined
const Probe = (p: { actions: typeof captured }) => {
captured = p.actions
return null
}
render(createElement('div', null, injected.renderView({
id: 'chat', label: 'Chat', component: Probe,
} as unknown as ViewEntry)))
captured!.openDetails({ turnSeq: 2, callId: 'c1' })
const { instance, injected } = b.conversationSurface(ROOT)
injected.openDetails({ turnSeq: 2, callId: 'c1' })
expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' })
expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
const scoped = b.mint(ROOT).get('conversation') as import('@deepseek-ai/dsh-client-ui-conversation/client').ConversationService
expect(scoped.selection.getSnapshot()).toEqual({ turnSeq: 2, callId: 'c1' })
captured!.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
})
it('renderView mounts chrome header/footer around the view body', async () => {
it('views read face forwards to the service registry (subscribe/version)', async () => {
const b = await bench()
const injected = b.entryOf('conversation').options.inject(b.binding) as {
renderView: (entry: ViewEntry) => React.ReactNode
}
const entry = {
id: 'chat', label: 'Chat',
component: () => createElement('div', { 'data-testid': 'body' }),
chrome: {
header: () => createElement('div', { 'data-testid': 'hd' }),
footer: () => createElement('div', { 'data-testid': 'ft' }),
},
} as unknown as ViewEntry
const view = render(createElement('div', null, injected.renderView(entry)))
expect(view.getByTestId('hd')).toBeTruthy()
expect(view.getByTestId('body')).toBeTruthy()
expect(view.getByTestId('ft')).toBeTruthy()
// Ancestry and draft/active-view hooks execute inside a component tree.
const HookProbe = () => {
const injected2 = b.entryOf('conversation').options.inject(b.binding) as {
useAncestry: () => readonly { title: string }[]
useActiveView: () => string | undefined
composer: { useDraft: () => string }
}
const chain = injected2.useAncestry()
const active = injected2.useActiveView()
const draft = injected2.composer.useDraft()
return createElement('i', { 'data-testid': 'probe' }, `${chain.length}|${active ?? 'none'}|${draft}`)
}
const probe = render(createElement(HookProbe))
// Draft content carries over from the composer case (per-scope store is
// process-resident); the probe asserts hook wiring, not draft value.
expect(probe.getByTestId('probe').textContent).toMatch(/^1\|none\|/)
// A list-store update while mounted drives the ancestry selector's
// shallowEqual arm (same derived chain → short-circuit, no re-render churn).
await act(async () => {
b.sessionsFake.list.update((d: { byId: Record<string, { updatedAt: number }> }) => {
d.byId[ROOT]!.updatedAt = 2
})
})
expect(probe.getByTestId('probe').textContent).toMatch(/^1\|none\|/)
// The views read-face triple forwards to the service registry.
const injected3 = b.entryOf('conversation').options.inject(b.binding) as {
views: { list(): readonly { id: string }[]; subscribe(fn: () => void): () => void; version(): number }
}
expect(injected3.views.list().map(v => v.id)).toEqual(['chat'])
const beforeVersion = injected3.views.version()
const { injected } = b.conversationSurface(ROOT)
const before = injected.views.version()
const listener = vi.fn()
const unsub = injected3.views.subscribe(listener)
const conversation = b.ctx.get('conversation') as import('@deepseek-ai/dsh-client-ui-conversation/client').ConversationService
const offExtra = conversation.registerView({ id: 'chat2', label: 'X', component: () => null } as never)
const unsub = injected.views.subscribe(listener)
const conversation = b.ctx.get('conversation') as
import('@deepseek-ai/dsh-client-ui-conversation/client').ConversationService
const off = conversation.registerView({ id: 'chat2', label: 'X', component: () => null } as never)
expect(listener).toHaveBeenCalled()
expect(injected3.views.version()).toBeGreaterThan(beforeVersion)
offExtra()
expect(injected.views.version()).toBeGreaterThan(before)
expect(injected.views.list().map(v => v.id)).toEqual(['chat', 'chat2'])
off()
unsub()
})
})
describe('details and empty inject surfaces', () => {
it('details surface wires selection and closeDetails', async () => {
it('details injects the one layout callback; selection rides the shared store instead', async () => {
const b = await bench()
const injected = b.entryOf('details').options.inject(b.binding) as {
useSelection: unknown
actions: { closeDetails(): void }
}
expect(injected.useSelection).toBeTypeOf('function')
injected.actions.closeDetails()
const entry = b.entryOf('details')
const injected = (entry.inject as unknown as () => DetailsInjected)()
expect(Object.keys(injected)).toEqual(['closeDetails'])
injected.closeDetails()
expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1)
// The shared handle: details resolves the SAME instance conversation writes.
const conv = b.hostFace.storeOf(b.entryOf('conversation'), ROOT)
const details = b.hostFace.storeOf(entry, ROOT)
expect(details).toBe(conv)
})
it('empty surface derives the deduped cwd set with a per-state cache and starts sessions', async () => {
it('empty injects the startSession chain only (no store, cwds derive in-component)', async () => {
const b = await bench()
const injected = b.entryOf('conversation.empty').options.inject({ ctx: b.ctx }) as {
useCwds: (sel: (s: readonly string[]) => unknown, eq?: unknown) => unknown
actions: { startSession(opts: { text: string; mode: 'queue' }): Promise<void> }
}
const CwdsProbe = () => {
const cwds = injected.useCwds(s => s) as readonly string[]
return createElement('i', { 'data-testid': 'cwds' }, cwds.join(','))
}
const view = render(createElement(CwdsProbe))
expect(view.getByTestId('cwds').textContent).toBe('/proj')
await injected.actions.startSession({ text: 'go', mode: 'queue' })
const entry = b.entryOf('conversation.empty')
expect(entry.store).toBeUndefined()
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
expect(Object.keys(injected)).toEqual(['startSession'])
await injected.startSession({ text: 'go', mode: 'queue' })
expect(b.sessionsFake.create).toHaveBeenCalled()
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue')
})
})
@@ -1,19 +1,18 @@
// @vitest-environment jsdom
// apply wiring: services provided, chat view + footer chrome registered, the
// three slot registrations land against ui-layout-shaped specs, and the bash
// samples resolve differentially (sub-session default scope). Full-chain
// rendering belongs to the shell e2e; this spec stops at the assembly surface.
// three slot registrations land against a root entry's children declarations
// (the AppFrame role), the shared store handle rides both session slots, and
// the bash samples resolve differentially (sub-session default scope).
// Full-chain rendering belongs to the shell e2e; this spec stops at the
// assembly surface.
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/src/store/index.ts'
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'
import type { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls ui-layout's SlotMap declaration merge into this spec's
// program so the slot keys below typecheck in the client lane.
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
const ROOT = 'root-1' as SessionId
const CHILD = 'child-1' as SessionId
@@ -29,31 +28,42 @@ async function bench() {
[ROOT]: { id: ROOT, title: 'R', running: false, updatedAt: 1 },
[CHILD]: { id: CHILD, title: 'C', parentId: ROOT, running: false, updatedAt: 2 },
},
})
current: undefined,
} as SessionListState)
const sessionsFake = {
list: listStore,
manager: { get: vi.fn() },
ancestry: () => [],
scope: () => undefined,
cell: () => undefined,
create: vi.fn(),
open: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
ctx.provide('layout', {
current: createSnapshotStore<{ viewFor: Record<string, string> }>({ viewFor: {} }),
open: vi.fn(), openView: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(),
})
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('i18n', { bind: () => (key: string) => key })
// Specs owned by ui-layout in production; declared here so registrations land.
// Declared by ui-layout's root entry in production; a stand-in root
// occupant declares them here so the contributions land (it consumes
// renderSlot to satisfy the declare-means-render check).
const slots = ctx.get('slots') as SlotsService
slots.define('conversation', { kind: 'single', scope: 'session' })
slots.define('details', { kind: 'single', scope: 'session' })
slots.define('conversation.empty', { kind: 'single', scope: 'root' })
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, (_p: { renderSlot?: unknown }) => null)
const fiber = ctx.plugin({ inject: [...inject], apply })
return { ctx, fiber, slots }
}
/** First stored entry for a key (inject/store live directly on StoredEntry). */
function renderEntryOf(slots: SlotsService, key: 'conversation' | 'details' | 'conversation.empty') {
return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
}
describe('apply wiring', () => {
it('provides conversation and toolviews services', async () => {
const b = await bench()
@@ -71,14 +81,20 @@ describe('apply wiring', () => {
expect(views[0]?.chrome?.footer).toBeDefined()
})
it('occupies conversation/details/conversation.empty with inject factories', async () => {
it('occupies the three slots; session pair shares one store handle, empty declares none', async () => {
const b = await bench()
await b.fiber.await()
for (const key of ['conversation', 'details', 'conversation.empty'] as const) {
const entries = b.slots.entries(key)
expect(entries, key).toHaveLength(1)
expect((entries[0]!.options as { inject?: unknown }).inject, key).toBeTypeOf('function')
}
const conversation = renderEntryOf(b.slots, 'conversation')
const details = renderEntryOf(b.slots, 'details')
const empty = renderEntryOf(b.slots, 'conversation.empty')
expect(conversation?.inject).toBeTypeOf('function')
expect(details?.inject).toBeTypeOf('function')
expect(empty?.inject).toBeTypeOf('function')
// The shared handle: one apply-built store value on BOTH session entries.
expect(conversation?.store).toBeDefined()
expect(details?.store).toBe(conversation?.store)
// The empty slot is storeless (local state + useSessions derivation).
expect(empty?.store).toBeUndefined()
})
it('bash samples resolve differentially: scoped row for sub-sessions, global for roots', async () => {
@@ -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, createSessionProvider } from '@deepseek-ai/dsh-client-web-react'
import type { SessionBinding as ReactSessionBinding, UseSession } from '@deepseek-ai/dsh-client-web-react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
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'
@@ -92,34 +92,33 @@ describe('small branch tails', () => {
})
describe('ToolViewOutlet dispatch', () => {
it('caches the inject factory per (registration x binding) and merges its props', () => {
it('caches the inject factory per (registration x session) and merges its props', () => {
const registry = new ToolViewRegistry()
const inject = vi.fn(() => ({ extra: 'injected' }))
const inject = vi.fn((sessionId: SessionId) => ({ extra: `injected:${sessionId}` }))
registry.register('bash',
(p: ToolViewProps & { extra: string }) => <div data-testid="row">{p.extra}</div>,
{ inject })
// InjectedRow reads the session binding from context: mount through the
// real SessionProvider so the (factory x binding) cache path executes.
const binding: ReactSessionBinding = {
sessionId: SID,
session: { useSelector: (() => { throw new Error('unused') }) as never },
ctx: {},
}
const Provider = createSessionProvider({
useCurrent: () => SID,
resolveBinding: () => binding,
renderBody: () => (
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />
),
})
const view = render(<Provider />)
expect(view.getByTestId('row').textContent).toBe('injected')
// Pure props machinery: the outlet feeds its own sessionId to the
// factory — no provider/context needed (terminal channel form).
const view = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
expect(view.getByTestId('row').textContent).toBe(`injected:${SID}`)
expect(inject).toHaveBeenCalledTimes(1)
// Remount against the SAME binding: cache hit, factory not re-run.
// Remount under the SAME session: cache hit, factory not re-run.
view.unmount()
const second = render(<Provider />)
expect(second.getByTestId('row').textContent).toBe('injected')
const second = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
expect(second.getByTestId('row').textContent).toBe(`injected:${SID}`)
expect(inject).toHaveBeenCalledTimes(1)
// A different session is a distinct cache key: factory runs once more.
second.unmount()
const other = render(
<ToolViewOutlet registry={registry} sessionId={'s2' as SessionId} toolName="bash" viewProps={viewProps()} />,
)
expect(other.getByTestId('row').textContent).toBe('injected:s2')
expect(inject).toHaveBeenCalledTimes(2)
})
it('a crashing custom row falls back to GenericToolCard and retries on re-registration', () => {
@@ -154,6 +154,7 @@ describe('bash toolview samples', () => {
const scope = childSessionScope({
getSnapshot: () => ({
ids: [root, child],
current: undefined,
byId: {
[root]: { id: root, title: 'r', running: false, updatedAt: 0 },
[child]: { id: child, title: 'c', parentId: root, running: false, updatedAt: 0 },
@@ -0,0 +1,94 @@
// @vitest-environment jsdom
/**
* createChatStore unit account (slot terminal design §4): the declared
* actions write set, persist round-trip through the scope-suffixed key, and
* factory purity (every create() is an independent instance; the factory
* itself holds no singleton state).
*/
import { beforeEach, describe, expect, it } from 'vitest'
import { createChatStore } from '../src/client/stores.ts'
const KEY = 'dsh.conversation.chat'
beforeEach(() => {
localStorage.clear()
})
describe('createChatStore', () => {
it('init shape: empty selection/draft/view', () => {
const store = createChatStore().create()
expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
})
it('actions cover the declared write set', () => {
const store = createChatStore().create()
store.actions.select({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
expect(store.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
store.actions.select(null)
expect(store.store.getSnapshot().selection).toBeNull()
store.actions.setDraft('hello')
expect(store.store.getSnapshot().draft).toBe('hello')
store.actions.clearDraft()
expect(store.store.getSnapshot().draft).toBe('')
store.actions.setView('chat')
expect(store.store.getSnapshot().view).toBe('chat')
})
it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => {
const store = createChatStore().create()
// Rollback path: draft was cleared by send, nothing typed since.
store.actions.restoreDraft('failed text')
expect(store.store.getSnapshot().draft).toBe('failed text')
// The user typed something new before the failure landed: keep theirs.
store.actions.setDraft('newer input')
store.actions.restoreDraft('stale text')
expect(store.store.getSnapshot().draft).toBe('newer input')
})
it('persists per scope key and rehydrates a fresh instance', () => {
const handle = createChatStore()
const s1 = handle.create('sess-1')
s1.actions.setDraft('draft for one')
s1.actions.select({ turnSeq: 1 })
// Scope-suffixed key: each session persists separately.
expect(localStorage.getItem(`${KEY}.sess-1`)).not.toBeNull()
expect(localStorage.getItem(`${KEY}.sess-2`)).toBeNull()
// A rebuilt instance under the same scope key rehydrates the state.
const again = createChatStore().create('sess-1')
expect(again.store.getSnapshot().draft).toBe('draft for one')
expect(again.store.getSnapshot().selection).toEqual({ turnSeq: 1 })
// A sibling scope starts clean.
const other = createChatStore().create('sess-2')
expect(other.store.getSnapshot().draft).toBe('')
})
it('clearPersisted removes the scope entry (session-death cleanup hook)', () => {
const store = createChatStore().create('sess-9')
store.actions.setDraft('doomed')
expect(localStorage.getItem(`${KEY}.sess-9`)).not.toBeNull()
store.clearPersisted()
expect(localStorage.getItem(`${KEY}.sess-9`)).toBeNull()
})
it('every create() is an independent instance; the factory holds no singleton', () => {
const handle = createChatStore()
const a = handle.create()
const b = handle.create()
a.actions.setDraft('only in a')
expect(b.store.getSnapshot().draft).toBe('')
// Two factory calls likewise share no LIVE state (identity is per handle
// VALUE, not per module — the sharing contract lives in the framework's
// handle x scope-key resolution, not in module state). Persistence is the
// one sanctioned cross-instance channel: clear it so this assertion sees
// memory identity, not rehydration (covered by the persist case above).
localStorage.clear()
const c = createChatStore().create()
expect(c.store.getSnapshot().draft).toBe('')
})
})
@@ -3,7 +3,7 @@
// toolview dispatch and selection handoff — driven through a scripted
// ObservableSnapshot fake, no wire.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
@@ -13,10 +13,16 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
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'
import { createChatView } from '../src/client/chat/ChatView.tsx'
import { deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
afterEach(cleanup)
// Keyless create() persists under the bare declared key; clear between cases
// so one harness's selection cannot rehydrate into the next.
beforeEach(() => {
localStorage.clear()
})
const SID = 's1' as SessionId
@@ -68,33 +74,17 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
const ChatView = createChatView({ toolviews: registry, t: (k) => k })
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const loadOlder = vi.fn()
const selection = makeSelection()
// Selection rides the REAL chat store (same construction path as
// production; the view reads it through the ConvViewProps useStore share).
const chat = createChatStore().create()
const props: ConvViewProps = {
sessionId: SID,
useSession: bindSnapshotSelector(source) as unknown as UseSession,
useSelection: bindSnapshotSelector(selection.source),
useStore: chat.useSelector,
actions: { openDetails, loadOlder },
slots: { renderSlot: () => null } as never,
}
return { set, registry, ChatView, props, openDetails, loadOlder, setSelection: selection.set }
}
function makeSelection() {
let sel: SelectionTarget | null = null
const subs = new Set<() => void>()
return {
set(next: SelectionTarget | null) {
sel = next
for (const fn of [...subs]) fn()
},
source: {
getSnapshot: () => sel,
subscribe: (fn: () => void) => {
subs.add(fn)
return () => subs.delete(fn)
},
},
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
return { set, registry, ChatView, props, openDetails, loadOlder, setSelection }
}
describe('chat-flow derivation', () => {
@@ -1,23 +1,24 @@
// @vitest-environment jsdom
// Final branch tails for the coverage gate, post slot-phase-2: apply's need()
// throw + cwd cache hit/empty-cwd skip, AssistantMarkdown non-final reasoning,
// StatsLine usage-less node, ChatView tool-group selected passthrough +
// running-empty guard, DetailsPanel titleless selection, registry disposer
// after a foreign removal emptied the list.
// Final branch tails for the coverage gate, terminal slot form: apply's
// need() throw, AssistantMarkdown non-final reasoning, StatsLine usage-less
// node, DetailsPanel titleless selection, registry disposer after a foreign
// removal emptied the list. (The old cwd WeakMap-cache account retired with
// the mechanism — derivation lives in EmptyState now, covered by the
// skeleton specs.)
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { Context } from 'cordis'
import { createSnapshotStore, bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-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 { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
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 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'
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
afterEach(cleanup)
@@ -42,40 +43,6 @@ describe('apply need() and cwd cache', () => {
expect(() => { (apply as (c: Context) => void)(ctx) }).toThrow(/sessions service unavailable/)
})
it('cwd derivation caches per list state and skips empty cwd values', async () => {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const listStore = createSnapshotStore<SessionListState>({
ids: [SID, 'x2' as SessionId, 'x3' as SessionId],
byId: {
[SID]: { id: SID, title: 'a', cwd: '/proj', running: false, updatedAt: 1 },
['x2' as SessionId]: { id: 'x2' as SessionId, title: 'b', cwd: '', running: false, updatedAt: 1 },
['x3' as SessionId]: { id: 'x3' as SessionId, title: 'c', running: false, updatedAt: 1 },
},
})
ctx.provide('sessions', { list: listStore, manager: { get: vi.fn() }, ancestry: () => [], scope: () => undefined, create: vi.fn() })
ctx.provide('layout', { current: createSnapshotStore<{ viewFor: Record<string, string> }>({ viewFor: {} }), open: vi.fn(), openView: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('i18n', { bind: () => (k: string) => k })
const slots = ctx.get('slots') as SlotsService
slots.define('conversation', { kind: 'single', scope: 'session' })
slots.define('details', { kind: 'single', scope: 'session' })
slots.define('conversation.empty', { kind: 'single', scope: 'root' })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const entry = slots.entries('conversation.empty')[0]! as unknown as {
options: { inject: (b: unknown) => { useCwds: (sel: (s: readonly string[]) => readonly string[]) => readonly string[] } }
}
const injected = entry.options.inject({ ctx })
const Probe = () => {
const cwds = injected.useCwds(s => s)
const again = injected.useCwds(s => s)
// Cache hit: same state object yields the same derived array reference.
return <i data-testid="cwds">{`${cwds.join(',')}|${String(cwds === again)}`}</i>
}
const view = render(<Probe />)
expect(view.getByTestId('cwds').textContent).toBe('/proj|true')
})
})
describe('render branch tails', () => {
@@ -101,7 +68,7 @@ describe('render branch tails', () => {
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine sessionId={SID} useSession={bindSnapshotSelector(source) as unknown as UseSession} />,
<StatsLine sessionId={SID} useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
)
expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
})
@@ -114,13 +81,20 @@ describe('render branch tails', () => {
})
it('DetailsPanel title falls to 详情 when the selection has no toolName and no material', () => {
const SEL: SelectionTarget = { turnSeq: 1, callId: 'ghost' }
localStorage.clear()
const snap = snapshotBase()
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)
const view = render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snapshotBase(), subscribe: () => () => {} }) as unknown as UseSession}
useSelection={bindSnapshotSelector({ getSnapshot: () => SEL, subscribe: () => () => {} })}
actions={{ closeDetails: vi.fn() }}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
useSessions={emptyList.useSelector}
useStore={chat.useSelector}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
expect(view.getByText('详情')).toBeTruthy()
@@ -1,16 +1,19 @@
// @vitest-environment jsdom
/**
* M1a regression pin: the per-scope selection must survive list refreshes.
* Drives the REAL SessionsService + ConversationService chain over the
* programmable wire fake — a late list refresh that upgrades the display
* title (bare id → cwd basename) and a reconnect-driven refreshList+resync
* must neither recreate the session scope nor clear the selection account.
* Selection survival across the store seat (terminal design §4): the chat
* store now carries what the per-scope selection account used to — this pins
* the same behavior contract in the new mechanism. Drives the REAL
* SlotsService store axis with the shared createChatStore handle (the exact
* apply.ts shape: one handle, two session-slot registrations): same session's
* two slots resolve one instance (conversation writes, details reads);
* sessions are isolated; a session's death buries its instance AND its
* persisted draft; a list refresh does not touch instance identity.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { beforeEach, describe, expect, it } from 'vitest'
import { SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { createChatStore } from '../src/client/stores.ts'
// The runtime package's programmable fake lives in its tests; import through
// the src path (same pattern the runtime specs use — test-support material).
@@ -22,15 +25,31 @@ interface Bench {
ctx: Context
api: FakeApiClient
sessions: SessionsService
conversation: ConversationService
slots: SlotsService
chat: ReturnType<typeof createChatStore>
}
function bench(): Bench {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const conversation = new ConversationService(ctx)
return { ctx, api, sessions, conversation }
// Service self-registers as ctx 'slots' (cordis Service constructor).
const slots = new SlotsService(ctx)
const chat = createChatStore()
// The apply.ts shape: one shared handle across both session-slot
// registrations. 'conversation'/'details' must first exist in the ledger —
// register a root occupant declaring them (the AppFrame role; the stand-in
// consumes renderSlot to satisfy the declare-means-render check).
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
},
}, (_p: { renderSlot?: unknown }) => null)
slots.register({ name: 'conversation', store: chat }, () => null)
slots.register({ name: 'details', store: chat }, () => null)
return { ctx, api, sessions, slots, chat }
}
async function flush(): Promise<void> {
@@ -48,8 +67,63 @@ function feed(b: Bench, rows: { id: string; cwd?: string; running?: boolean }[])
}) as never)
}
describe('selection survives list refreshes (M1a)', () => {
it('create → select → title-upgrading refresh keeps scope, binding, store and value', async () => {
/** Resolve the store instance the renderer would hand a slot's component for a session. */
function storeFor(b: Bench, slot: 'conversation' | 'details', sessionId: SessionId) {
const host = renderHost(b)
const entry = host.entriesOf(slot)[0]!
return host.storeOf(entry, sessionId)! as ReturnType<ReturnType<typeof createChatStore>['create']>
}
/** 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 })
if (captured._host === undefined) {
b.slots.install({
renderRoot: (host) => {
captured._host = host
return null
},
})
b.slots.renderSlot('root', {})
}
return captured._host!
}
beforeEach(() => {
localStorage.clear()
})
describe('selection survives on the store seat', () => {
it('one session, two slots: conversation writes, details reads the SAME instance', async () => {
const b = bench()
feed(b, [{ id: 's1' }])
await b.sessions.manager.refreshList()
await flush()
const conv = storeFor(b, 'conversation', sid('s1'))
const details = storeFor(b, 'details', sid('s1'))
conv.actions.select({ turnSeq: 3, callId: 'c1' })
expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
// Identity, not just value: the shared handle resolves one instance per scope key.
expect(details).toBe(conv)
})
it('sessions are isolated: s2 selection never bleeds into s1', async () => {
const b = bench()
feed(b, [{ id: 's1' }, { id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
const one = storeFor(b, 'conversation', sid('s1'))
const two = storeFor(b, 'conversation', sid('s2'))
expect(two).not.toBe(one)
one.actions.select({ turnSeq: 1, callId: 'a' })
two.actions.select({ turnSeq: 9, callId: 'z' })
expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' })
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
})
it('a title-upgrading list refresh keeps instance identity and the selection value', async () => {
const b = bench()
// First-send shape: client-side create inserts the row without cwd (title = bare id).
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s1') }))
@@ -57,11 +131,9 @@ describe('selection survives list refreshes (M1a)', () => {
await flush()
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('s1')
const binding = b.sessions.binding(id)
expect(binding).toBeDefined()
const scoped = b.sessions.scope(id)!
const store = (scoped.get('conversation') as ConversationService).selection
store.set({ turnSeq: 3, callId: 'c1' })
const store = storeFor(b, 'conversation', id)
store.actions.select({ turnSeq: 3, callId: 'c1' })
store.actions.setDraft('half-typed')
// The late list refresh lands (host knows the cwd → formal title).
feed(b, [{ id: 's1', cwd: '/w/proj-a' }])
@@ -69,53 +141,40 @@ describe('selection survives list refreshes (M1a)', () => {
await flush()
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('proj-a')
// Scope, binding and the selection account must all be identity-stable.
expect(b.sessions.scope(id)).toBe(scoped)
expect(b.sessions.binding(id)).toBe(binding)
const after = (b.sessions.scope(id)!.get('conversation') as ConversationService).selection
const after = storeFor(b, 'conversation', id)
expect(after).toBe(store)
expect(after.getSnapshot()).toEqual({ turnSeq: 3, callId: 'c1' })
expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
expect(after.store.getSnapshot().draft).toBe('half-typed')
})
it('reconnect (handleConnected: refreshList + resync) keeps the selection account', async () => {
it('session death buries the instance and its persisted draft', async () => {
const b = bench()
feed(b, [{ id: 's1' }])
feed(b, [{ id: 's1' }, { id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
const scoped = b.sessions.scope(sid('s1'))!
const store = (scoped.get('conversation') as ConversationService).selection
store.set({ turnSeq: 1, callId: 'c9' })
// Mint the scope (store prune rides the scope-teardown axis: no scope,
// no teardown — the real page always resolves the binding to render).
b.sessions.binding(sid('s1'))
const doomed = storeFor(b, 'conversation', sid('s1'))
doomed.actions.setDraft('to be buried')
doomed.actions.select({ turnSeq: 1 })
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
// Reconnect generation: title upgrade arrives with the re-pull.
feed(b, [{ id: 's1', cwd: '/w/proj-a', running: true }])
b.sessions.manager.handleConnected()
await flush()
await flush()
expect(b.sessions.scope(sid('s1'))).toBe(scoped)
const after = (b.sessions.scope(sid('s1'))!.get('conversation') as ConversationService).selection
expect(after).toBe(store)
expect(after.getSnapshot()).toEqual({ turnSeq: 1, callId: 'c9' })
})
it('a transiently failing list refresh does not prune live scopes', async () => {
const b = bench()
feed(b, [{ id: 's1' }])
// Watch elsewhere so s1's scope teardown is not deferred, then remove it.
b.sessions.binding(sid('s2'))
feed(b, [{ id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
const scoped = b.sessions.scope(sid('s1'))!
const store = (scoped.get('conversation') as ConversationService).selection
store.set({ turnSeq: 2, callId: 'c2' })
// Wire hiccup: the reconnect-time list RPC throws (transport error).
b.api.onList = () => Promise.reject(new Error('boom'))
b.sessions.manager.handleConnected()
// Persisted residue is gone with the session...
expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
// ...and a re-created same-id session starts from a FRESH instance.
feed(b, [{ id: 's1' }, { id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
await flush()
expect(b.sessions.scope(sid('s1'))).toBe(scoped)
expect((b.sessions.scope(sid('s1'))!.get('conversation') as ConversationService).selection.getSnapshot())
.toEqual({ turnSeq: 2, callId: 'c2' })
const reborn = storeFor(b, 'conversation', sid('s1'))
expect(reborn).not.toBe(doomed)
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
})
})
@@ -1,9 +1,10 @@
// @vitest-environment jsdom
/**
* ConversationService orchestration half: scope-addressed send/cancel (result
* folding, root throw), openDetails choreography, the startSession chain, and
* the service-unavailable loud failures. Store semantics live in
* service-stores.spec.ts.
* ConversationService orchestration half after the store-seat slimming:
* scope-addressed send/cancel (result folding, root throw), the startSession
* chain (create → sessions.open → scoped send), views ordering, and the
* service-unavailable loud failures. Selection/draft state left this service
* for the declared chat store (chat-store.spec.ts / selection-survival.spec.ts).
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
@@ -13,7 +14,7 @@ import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/cli
const sid = (s: string): SessionId => s as SessionId
/** Recover the module-private scope tag through the public seam (same probe as service-stores.spec). */
/** Recover the module-private scope tag through the public seam (same probe as apply-inject.spec). */
const SCOPE_TAG: symbol = (() => {
const recorded: (string | symbol)[] = []
const spy = new Proxy(new Context(), {
@@ -33,7 +34,7 @@ interface SessionDouble {
cancel: ReturnType<typeof vi.fn>
}
async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
async function bench(opts?: { sessions?: boolean }) {
const ctx = new Context()
const sessionDoubles = new Map<SessionId, SessionDouble>()
const scopes = new Map<SessionId, Context>()
@@ -47,6 +48,7 @@ async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
return scoped
}
const createMock = vi.fn(() => Promise.resolve(sid('new-1')))
const openMock = vi.fn()
const sessionsFake = {
manager: {
get: (id: SessionId) => {
@@ -62,16 +64,15 @@ async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
},
},
create: createMock,
open: openMock,
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
} as unknown as SessionsService
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
const layoutFake = { open: vi.fn(), openDetails: vi.fn() }
if (opts?.layout !== false) ctx.provide('layout', layoutFake)
const fiber = ctx.plugin((pluginCtx) => { void new ConversationService(pluginCtx) })
await fiber.await()
const svc = ctx.get('conversation') as ConversationService
const scopedSvc = (id: SessionId) => mint(id).get('conversation') as ConversationService
return { ctx, svc, scopedSvc, mint, sessionDoubles, sessionsFake, createMock, layoutFake }
return { ctx, svc, scopedSvc, mint, sessionDoubles, sessionsFake, createMock, openMock }
}
describe('send / cancel', () => {
@@ -109,22 +110,12 @@ describe('send / cancel', () => {
})
})
describe('openDetails', () => {
it('writes the scoped selection then opens the layout panel', async () => {
const b = await bench()
const s = b.scopedSvc(sid('s1'))
s.openDetails({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
expect(s.selection.getSnapshot()).toEqual({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
})
})
describe('startSession chain', () => {
it('creates, navigates, then sends through the new scope', async () => {
it('creates, navigates through sessions.open, then sends through the new scope', async () => {
const b = await bench()
await b.svc.startSession({ cwd: '/proj', text: 'first', mode: 'queue' })
expect(b.createMock).toHaveBeenCalledWith({ cwd: '/proj' })
expect(b.layoutFake.open).toHaveBeenCalledWith(sid('new-1'))
expect(b.openMock).toHaveBeenCalledWith(sid('new-1'))
expect(b.sessionDoubles.get(sid('new-1'))!.prompt).toHaveBeenCalledWith(
[{ type: 'text', text: 'first' }], 'queue')
})
@@ -148,12 +139,6 @@ describe('service-unavailable loud failures', () => {
await expect(b.svc.startSession({ text: 't', mode: 'queue' })).rejects.toThrow(/sessions service unavailable/)
})
it('throws when layout is missing', async () => {
const b = await bench({ layout: false })
const s = b.scopedSvc(sid('s1'))
expect(() => { s.openDetails({ turnSeq: 1 }) }).toThrow(/layout service unavailable/)
})
it('startSession fails loud when the new scope cannot resolve conversation', async () => {
const b = await bench()
// A scope minted outside the service tree: scoped.get('conversation') finds nothing.
@@ -165,7 +150,7 @@ describe('service-unavailable loud failures', () => {
})
})
describe('views ordering and draft persistence branches', () => {
describe('views ordering', () => {
it('orders by explicit order with undefined treated as zero (both comparator arms)', async () => {
const b = await bench()
const entry = (id: string, order?: number) => ({
@@ -177,15 +162,4 @@ describe('views ordering and draft persistence branches', () => {
b.svc.registerView(entry('first', -1) as never)
expect(b.svc.views().map(v => v.id)).toEqual(['first', 'default-zero', 'z-late'])
})
it('draft store round-trips through localStorage and removes the key when emptied', async () => {
const b = await bench()
localStorage.setItem('dsh.conversation.draft.s9', 'restored')
const s = b.scopedSvc(sid('s9'))
expect(s.drafts.getSnapshot()).toBe('restored')
s.drafts.set('typed')
expect(localStorage.getItem('dsh.conversation.draft.s9')).toBe('typed')
s.drafts.set('')
expect(localStorage.getItem('dsh.conversation.draft.s9')).toBeNull()
})
})
@@ -1,176 +0,0 @@
// @vitest-environment jsdom
/**
* ConversationService store half: scope-addressed selection/drafts accounts
* (lazy mint, per-scope isolation, root access throws, scope teardown
* collects), view registry (order, duplicate throw, effect-scoped disposal,
* uSES read face). Send/cancel/startSession orchestration live in
* service-orchestration.spec.ts.
*/
import { Context } from 'cordis'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConvViewProps, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
const sid = (s: string): SessionId => s as SessionId
/**
* The scope tag symbol is module-private to the runtime package; recover it
* through the public seam by recording which symbol scopeOf reads off a
* spying proxy (keeps this bench honest against the real tagging shape
* without dragging the full SessionsService + wire fake in here).
*/
const SCOPE_TAG: symbol = (() => {
const recorded: (string | symbol)[] = []
const spy = new Proxy(new Context(), {
get(target, prop, receiver): unknown {
recorded.push(prop)
return Reflect.get(target, prop, receiver)
},
})
void scopeOf(spy)
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
return symbol
})()
/** Scope bench: real cordis scope fibers tagged like SessionsService.resolve mints them. */
interface Bench {
ctx: Context
svc: ConversationService
mint: (id: SessionId) => Context
dispose: (id: SessionId) => Promise<void>
}
function bench(): Bench {
const ctx = new Context()
const fibers = new Map<SessionId, { fiber: ReturnType<Context['plugin']>; ctx: Context }>()
const mint = (id: SessionId): Context => {
let rec = fibers.get(id)
if (rec === undefined) {
const fiber = ctx.plugin(() => {})
const scoped = fiber.ctx.extend({ [SCOPE_TAG]: id })
rec = { fiber, ctx: scoped }
fibers.set(id, rec)
}
return rec.ctx
}
const dispose = async (id: SessionId): Promise<void> => {
const rec = fibers.get(id)
if (rec !== undefined) {
await rec.fiber.dispose()
fibers.delete(id)
}
}
const sessions = { scope: (id: SessionId) => fibers.get(id)?.ctx } as unknown as SessionsService
ctx.provide('sessions', sessions)
const svc = new ConversationService(ctx)
return { ctx, svc, mint, dispose }
}
/** Scoped service view: ctx.get binds the root singleton to the scoped ctx (scope addressing seam). */
function convo(scoped: Context): ConversationService {
const service = scoped.get('conversation')
if (service === undefined) throw new Error('bench: conversation unavailable')
return service
}
const viewComp = (() => null) as unknown as FC<ConvViewProps>
const entry = (id: string, order?: number): ViewEntry =>
({ id, label: id, component: viewComp, ...(order !== undefined ? { order } : {}) }) as unknown as ViewEntry
beforeEach(() => { localStorage.clear() })
describe('scope addressing of stores', () => {
it('root-context selection/drafts access throws with the addressing hint', () => {
const b = bench()
expect(() => b.svc.selection).toThrow(/requires a session scope/)
expect(() => b.svc.drafts).toThrow(/requires a session scope/)
})
it('mints one store per scope and keeps identity per session', () => {
const b = bench()
const c1 = b.mint(sid('s1'))
const c2 = b.mint(sid('s2'))
const sel1 = convo(c1).selection
const sel2 = convo(c2).selection
expect(sel1).not.toBe(sel2)
expect(convo(c1).selection).toBe(sel1)
sel1.set({ turnSeq: 3 })
expect(sel1.getSnapshot()).toEqual({ turnSeq: 3 })
expect(sel2.getSnapshot()).toBeNull()
})
it('persists drafts keyed by session id and evolves independently', async () => {
const b = bench()
const c1 = b.mint(sid('s1'))
convo(c1).drafts.set('hello')
expect(localStorage.getItem('dsh.conversation.draft.s1')).toBe('hello')
const c2 = b.mint(sid('s2'))
expect(convo(c2).drafts.getSnapshot()).toBe('')
// Re-minting after teardown rehydrates from storage; clearing removes the key.
await b.dispose(sid('s1'))
expect(convo(b.mint(sid('s1'))).drafts.getSnapshot()).toBe('hello')
convo(b.mint(sid('s1'))).drafts.set('')
expect(localStorage.getItem('dsh.conversation.draft.s1')).toBeNull()
})
it('scope fiber disposal collects the store account (fresh store on re-mint)', async () => {
const b = bench()
const c1 = b.mint(sid('s1'))
const sel = convo(c1).selection
sel.set({ turnSeq: 1 })
await b.dispose(sid('s1'))
const again = b.mint(sid('s1'))
const sel2 = convo(again).selection
expect(sel2).not.toBe(sel)
expect(sel2.getSnapshot()).toBeNull()
})
})
describe('view registry', () => {
it('orders by order (ties keep registration sequence) with a stable cache reference', () => {
const b = bench()
b.svc.registerView(entry('chat', 0))
b.svc.registerView(entry('waterfall', 2))
b.svc.registerView(entry('trajectory', 1))
const views = b.svc.views()
expect(views.map(v => v.id)).toEqual(['chat', 'trajectory', 'waterfall'])
expect(b.svc.views()).toBe(views)
})
it('duplicate id throws; disposer removes and bumps the version', () => {
const b = bench()
const fn = vi.fn()
b.svc.subscribeViews(fn)
const off = b.svc.registerView(entry('chat'))
expect(() => b.svc.registerView(entry('chat'))).toThrow(/already registered/)
const v1 = b.svc.viewsVersion()
off()
expect(b.svc.viewsVersion()).toBeGreaterThan(v1)
expect(b.svc.views()).toEqual([])
expect(fn).toHaveBeenCalled()
})
it('unsubscribe stops notifications', () => {
const b = bench()
const fn = vi.fn()
const unsub = b.svc.subscribeViews(fn)
unsub()
b.svc.registerView(entry('chat'))
expect(fn).not.toHaveBeenCalled()
})
it('a registering plugin fiber unloading collects its views (effect cascade)', async () => {
const b = bench()
const fiber = b.ctx.plugin((pluginCtx: Context) => {
convo(pluginCtx).registerView(entry('chat'))
})
await fiber.await()
expect(b.svc.views().map(v => v.id)).toEqual(['chat'])
await fiber.dispose()
expect(b.svc.views()).toEqual([])
})
})
@@ -1,16 +1,19 @@
// @vitest-environment jsdom
// Skeleton branch tails for the coverage gate (complements skeleton.spec.tsx
// acceptance flows): breadcrumb ancestry rendering + error strip in
// ConversationRoot, DetailsPanel non-JSON args / non-text result blocks /
// error-only results, EmptyState failure surface and custom-directory swap.
// acceptance flows), four-share props form: breadcrumb ancestry derivation +
// error strip in ConversationRoot, DetailsPanel non-JSON args / non-text
// result blocks / error-only results over the shared store, EmptyState
// failure surface and custom-directory swap with in-component cwd derivation.
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 type { ConversationSnapshot, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
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.
import { createChatStore } from '../src/client/stores.ts'
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
@@ -35,37 +38,52 @@ function sessionSource(over?: Partial<ConversationSnapshot>) {
}
}
const summary = (id: string, title: string): SessionSummary =>
({ id: id as SessionId, title, running: false, updatedAt: 1 })
/** Sessions-list stub over a snapshot store (the standard useSessions hook shape). */
function listHook(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) {
const store = createSnapshotStore<SessionListState>({
ids: rows.map(r => r.id as SessionId),
byId: Object.fromEntries(rows.map(r => [r.id, {
id: r.id as SessionId, title: r.title, running: false, updatedAt: 1,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentId: r.parentId as SessionId } : {}),
}])),
current: undefined,
} as SessionListState)
return store.useSelector
}
describe('ConversationRoot branches', () => {
const chatEntry: ViewEntry = {
id: 'chat', label: 'Chat', component: () => null,
id: 'chat', label: 'Chat', component: () => <div data-testid="view-body" />,
} as unknown as ViewEntry
function rootProps(over?: {
ancestry?: readonly SessionSummary[]
rows?: { id: string; title: string; parentId?: string }[]
snapshot?: Partial<ConversationSnapshot>
}) {
const open = vi.fn()
const chat = createChatStore().create()
const view = render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource(over?.snapshot)) as unknown as UseSession}
useAncestry={() => over?.ancestry ?? []}
useSession={bindSnapshotSelector(sessionSource(over?.snapshot)) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook(over?.rows ?? [])}
useStore={chat.useSelector}
actions={chat.actions}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
useActiveView={() => undefined}
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
actions={{ openView: vi.fn(), open }}
renderView={() => <div data-testid="view-body" />}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
loadOlder={vi.fn()}
open={open}
/>,
)
return { view, open }
return { view, open, chat }
}
it('renders the ancestry breadcrumb with separators and navigates on ancestor click', () => {
it('derives the ancestry breadcrumb from the sessions list and navigates on ancestor click', () => {
const { view, open } = rootProps({
ancestry: [summary('root-1', 'Workspace'), summary('s1', 'Current')],
rows: [{ id: 'root-1', title: 'Workspace' }, { id: 's1', title: 'Current', parentId: 'root-1' }],
})
expect(view.getByText('Workspace')).toBeTruthy()
expect(view.getByText('/')).toBeTruthy()
@@ -76,6 +94,14 @@ describe('ConversationRoot branches', () => {
expect(open).toHaveBeenCalledTimes(1)
})
it('a broken parent link stops the ancestry walk at the known chain', () => {
const { view } = rootProps({
rows: [{ id: 's1', title: 'Orphan', parentId: 'vanished' }],
})
// The walk keeps s1 itself and stops where the parent is unknown.
expect(view.getByText('Orphan')).toBeTruthy()
})
it('falls back to the raw session id without ancestry and counts user turns', () => {
const { view } = rootProps({
snapshot: { nodes: [{ kind: 'user', seq: 1 } as never, { kind: 'assistant', seq: 2 } as never] },
@@ -91,31 +117,41 @@ describe('ConversationRoot branches', () => {
expect(view.getByText(/停止失败:haltinternal/)).toBeTruthy()
})
it('an unknown active view id falls back to the first registered view', () => {
it('an unknown stored view id falls back to the first registered view', () => {
const { chat } = rootProps({})
cleanup()
chat.actions.setView('gone' as never)
const view = render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource()) as unknown as UseSession}
useAncestry={() => []}
useSession={bindSnapshotSelector(sessionSource()) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={chat.useSelector}
actions={chat.actions}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
useActiveView={() => 'gone' as never}
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
actions={{ openView: vi.fn(), open: vi.fn() }}
renderView={(entry) => <div data-testid={`body-${entry.id}`} />}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
loadOlder={vi.fn()}
open={vi.fn()}
/>,
)
expect(view.getByTestId('body-chat')).toBeTruthy()
expect(view.getByTestId('view-body')).toBeTruthy()
})
})
describe('DetailsPanel branches', () => {
function panel(selection: SelectionTarget | null, snapshot?: Partial<ConversationSnapshot>) {
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
return render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource(snapshot)) as unknown as UseSession}
useSelection={bindSnapshotSelector({ getSnapshot: () => selection, subscribe: () => () => {} })}
actions={{ closeDetails: vi.fn() }}
useSession={bindSnapshotSelector(sessionSource(snapshot)) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={chat.useSelector}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
}
@@ -142,13 +178,16 @@ describe('DetailsPanel branches', () => {
return () => subs.delete(fn)
},
}
const SEL: SelectionTarget = { turnSeq: 1, callId: 'c9' }
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 1, callId: 'c9' })
const view = render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector(source) as unknown as UseSession}
useSelection={bindSnapshotSelector({ getSnapshot: () => SEL, subscribe: () => () => {} })}
actions={{ closeDetails: vi.fn() }}
useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={chat.useSelector}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
expect(view.getByText(/"a": 1/)).toBeTruthy()
@@ -192,18 +231,10 @@ describe('DetailsPanel branches', () => {
})
describe('EmptyState branches', () => {
// getSnapshot must return a stable reference (uSES contract) — a fresh
// array per call loops the selector forever.
const CWDS: readonly string[] = ['/proj']
const NO_CWDS: readonly string[] = []
it('keeps the draft and surfaces a local error strip when startSession rejects', async () => {
const startSession = vi.fn(() => Promise.reject(new Error('create down')))
const view = render(
<EmptyState
useCwds={bindSnapshotSelector({ getSnapshot: () => CWDS, subscribe: () => () => {} })}
actions={{ startSession }}
/>,
<EmptyState useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])} startSession={startSession} />,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'first task' } })
@@ -215,10 +246,7 @@ describe('EmptyState branches', () => {
it('non-Error rejection reasons stringify into the error strip', async () => {
const startSession = vi.fn(() => Promise.reject('plain-string'))
const view = render(
<EmptyState
useCwds={bindSnapshotSelector({ getSnapshot: () => NO_CWDS, subscribe: () => () => {} })}
actions={{ startSession }}
/>,
<EmptyState useSessions={listHook([])} startSession={startSession} />,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'go' } })
@@ -226,15 +254,20 @@ describe('EmptyState branches', () => {
await waitFor(() => expect(view.getByText(/发送失败:plain-string/)).toBeTruthy())
})
it('cwd select picks an option, swaps to free-form on 新目录, and submits the typed path', async () => {
it('cwd derivation skips blank cwds; select picks, swaps to free-form, submits the typed path', async () => {
const startSession = vi.fn(() => Promise.resolve())
const view = render(
<EmptyState
useCwds={bindSnapshotSelector({ getSnapshot: () => CWDS, subscribe: () => () => {} })}
actions={{ startSession }}
useSessions={listHook([
{ id: 'a', title: 'a', cwd: '/proj' },
{ id: 'b', title: 'b' }, // no cwd: filtered from the option set
])}
startSession={startSession}
/>,
)
const select = view.container.querySelector('select')!
expect([...(select as HTMLSelectElement).options].map(o => o.value))
.toEqual(['', '/proj', '::new-directory'])
fireEvent.change(select, { target: { value: '/proj' } })
expect((select as HTMLSelectElement).value).toBe('/proj')
fireEvent.change(select, { target: { value: '::new-directory' } })
@@ -1,19 +1,23 @@
// @vitest-environment jsdom
/**
* Skeleton acceptance: empty-state transition (same InputBar component in
* hero position, startSession submit), ConversationRoot view switching over
* the registry face, DetailsPanel open/close linkage against a layout-shaped
* fake. Components stay framework-free — everything arrives via props here,
* exactly as the inject factories will assemble them.
* Skeleton acceptance over the four-share props form: empty-state transition
* (same InputBar component in hero position, startSession submit, in-component
* cwd derivation), ConversationRoot view switching through the store's view
* field, DetailsPanel selection through the shared store. Components stay
* pure — the framework shares are stubbed (useSession/useSessions), the store
* share is a REAL createChatStore().create() instance (same construction path
* as production), injected callbacks are spies.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import { bindSnapshotSelector, createSnapshotStore } from '@deepseek-ai/dsh-client-web-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 type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type { SelectionTarget, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
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.
import { createChatStore } from '../src/client/stores.ts'
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
@@ -21,6 +25,9 @@ import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
const sid = (s: string): SessionId => s as SessionId
afterEach(cleanup)
beforeEach(() => {
localStorage.clear()
})
/** Minimal conversation snapshot slice the skeleton reads. */
interface FakeSnapshot {
@@ -35,17 +42,38 @@ function fakeSession(init: Partial<FakeSnapshot> = {}) {
const store = createSnapshotStore<FakeSnapshot>({
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init,
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession }
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
/** Sessions-list stub: the standard useSessions hook over a snapshot store. */
function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) {
const store = createSnapshotStore<SessionListState>({
ids: rows.map(r => sid(r.id)),
byId: Object.fromEntries(rows.map(r => [r.id, {
id: sid(r.id), title: r.title, running: false, updatedAt: 1,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentId: sid(r.parentId) } : {}),
}])),
current: undefined,
} as SessionListState)
return { store, useSessions: store.useSelector }
}
describe('EmptyState', () => {
it('submits startSession with the typed text and picked cwd; failure surfaces locally', async () => {
const cwds = createSnapshotStore<readonly string[]>(['/w/app', '/w/lib'])
it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => {
const { useSessions } = fakeSessions([
{ id: 'a', title: 'a', cwd: '/w/app' },
{ id: 'b', title: 'b', cwd: '/w/lib' },
{ id: 'c', title: 'c', cwd: '/w/app' }, // duplicate cwd dedupes
])
let reject!: (e: Error) => void
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
render(<EmptyState useCwds={cwds.useSelector} actions={{ startSession }} />)
render(<EmptyState useSessions={useSessions} startSession={startSession} />)
fireEvent.change(screen.getByRole('combobox', { name: '项目目录' }), { target: { value: '/w/app' } })
const select = screen.getByRole('combobox', { name: '项目目录' })
expect([...(select as HTMLSelectElement).options].map(o => o.value))
.toEqual(['', '/w/app', '/w/lib', '::new-directory'])
fireEvent.change(select, { target: { value: '/w/app' } })
const box = screen.getByPlaceholderText('Message to run task, plan and build')
fireEvent.change(box, { target: { value: '造一个轮子' } })
fireEvent.keyDown(box, { key: 'Enter' })
@@ -58,8 +86,8 @@ describe('EmptyState', () => {
})
it('new-directory option swaps the select for a free-form input', () => {
const cwds = createSnapshotStore<readonly string[]>([])
render(<EmptyState useCwds={cwds.useSelector} actions={{ startSession: () => Promise.resolve() }} />)
const { useSessions } = fakeSessions([])
render(<EmptyState useSessions={useSessions} startSession={() => Promise.resolve()} />)
fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } })
const custom = screen.getByPlaceholderText(/目录路径/)
fireEvent.change(custom, { target: { value: '/tmp/fresh' } })
@@ -68,90 +96,117 @@ describe('EmptyState', () => {
})
describe('ConversationRoot', () => {
function bench(views: ViewEntry[], active?: string) {
function bench(views: ViewEntry[], activeView?: string) {
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }] })
const activeStore = createSnapshotStore<string | undefined>(active)
const openView = vi.fn((v: string) => { activeStore.set(v) })
const open = vi.fn()
const drafts = createSnapshotStore<string>('')
const { useSessions } = fakeSessions([
{ id: 'root', title: 'proj' },
{ id: 's1', title: 'child', parentId: 'root' },
])
const chat = createChatStore().create()
if (activeView !== undefined) chat.actions.setView(activeView as never)
const send = vi.fn()
const stop = vi.fn()
const ancestry: SessionSummary[] = [
{ id: sid('root'), title: 'proj', running: false, updatedAt: 1 },
{ id: sid('s1'), title: 'child', running: false, updatedAt: 1, parentId: sid('root') },
]
const rendered: string[] = []
const openDetails = vi.fn()
const loadOlder = vi.fn()
const open = vi.fn()
const ui = render(
<ConversationRoot
sessionId={sid('s1')}
useSession={useSession}
useAncestry={() => ancestry}
useSessions={useSessions}
useStore={chat.useSelector}
actions={chat.actions}
views={{
list: () => views,
subscribe: () => () => {},
version: () => 1,
}}
useActiveView={() => activeStore.useSelector(s => s) as ViewId | undefined}
composer={{
useDraft: () => drafts.useSelector(s => s),
setDraft: (t) => { drafts.set(t) },
send, stop,
}}
actions={{ openView: openView as (v: never) => void, open }}
renderView={(entry) => { rendered.push(entry.id); return <div data-testid={`view-${entry.id}`} /> }}
send={send}
stop={stop}
openDetails={openDetails}
loadOlder={loadOlder}
open={open}
/>)
return { ui, openView, open, rendered, send, drafts }
return { ui, chat, send, stop, open }
}
const comp = (() => null) as unknown as FC<never>
/** View bodies record their mount via testid (renderView is in-component now). */
const view = (id: string, label: string): ViewEntry =>
({ id, label, component: comp }) as unknown as ViewEntry
({
id, label,
component: (() => <div data-testid={`view-${id}`} />) as unknown as FC<never>,
}) as unknown as ViewEntry
it('renders breadcrumb chain, meta turns, and the active view (default chat)', () => {
const { rendered, open } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
it('renders breadcrumb chain (useSessions-derived), meta turns, and the default chat view', () => {
const { open } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
expect(screen.getByText('proj')).toBeTruthy()
expect(screen.getByText('child')).toBeTruthy()
expect(screen.getByText(/2 turns/)).toBeTruthy()
expect(rendered).toEqual(['chat'])
expect(screen.getByTestId('view-chat')).toBeTruthy()
// Ancestor crumb navigates; current crumb is disabled.
fireEvent.click(screen.getByRole('button', { name: 'proj' }))
expect(open).toHaveBeenCalledWith('root')
expect((screen.getByRole('button', { name: 'child' }) as HTMLButtonElement).disabled).toBe(true)
})
it('switches views through actions.openView and re-renders the new body', () => {
const { openView } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
it('switches views through the store view field and falls back on unknown ids', () => {
const { chat } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(openView).toHaveBeenCalledWith('trajectory')
expect(chat.store.getSnapshot().view).toBe('trajectory')
expect(screen.getByTestId('view-trajectory')).toBeTruthy()
cleanup()
// A stale persisted id (its view plugin unloaded) falls to the first view.
bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')], 'ghost-view')
expect(screen.getByTestId('view-chat')).toBeTruthy()
})
it('hides the tab strip with a single view and wires the composer send', () => {
const { send } = bench([view('chat', 'Chat')])
it('mounts chrome header/footer around the view body', () => {
const entry = {
id: 'chat', label: 'Chat',
component: () => <div data-testid="body" />,
chrome: {
header: () => <div data-testid="hd" />,
footer: () => <div data-testid="ft" />,
},
} as unknown as ViewEntry
bench([entry])
expect(screen.getByTestId('hd')).toBeTruthy()
expect(screen.getByTestId('body')).toBeTruthy()
expect(screen.getByTestId('ft')).toBeTruthy()
})
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
const { chat, send } = bench([view('chat', 'Chat')])
expect(screen.queryByRole('tablist')).toBeNull()
const box = screen.getByPlaceholderText(/输入消息/)
fireEvent.change(box, { target: { value: 'hi' } })
// Typing goes through actions.setDraft into the shared store.
expect(chat.store.getSnapshot().draft).toBe('hi')
fireEvent.keyDown(box, { key: 'Enter' })
expect(send).toHaveBeenCalledWith('queue')
expect(send).toHaveBeenCalledWith('hi', 'queue')
})
})
describe('DetailsPanel', () => {
function benchDetails(snapshot: Partial<FakeSnapshot>, selection: SelectionTarget | null) {
const { useSession } = fakeSession(snapshot)
const selectionStore = createSnapshotStore<SelectionTarget | null>(selection)
const { useSessions } = fakeSessions([])
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const closeDetails = vi.fn()
render(
<DetailsPanel
sessionId={sid('s1')}
useSession={useSession}
useSelection={selectionStore.useSelector}
actions={{ closeDetails }}
useSessions={useSessions}
useStore={chat.useSelector}
actions={chat.actions}
closeDetails={closeDetails}
/>)
return { closeDetails, selectionStore }
return { closeDetails, chat }
}
it('renders the selected call args and result; close fires the layout-linked action', () => {
it('renders the selected call args and result off the shared store; close fires the injected callback', () => {
const { closeDetails } = benchDetails({
nodes: [{
kind: 'tool-result', callId: 'c1',
@@ -8,7 +8,6 @@
// pinned here. Follows the slots-ring exemplar's shape.
import { describe, expect, it } from 'vitest'
import type { FC, ReactNode } from 'react'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolViewOptions, ToolViewProps } from '../src/client/contract/toolview.ts'
import { ToolViewRegistry } from '../src/client/toolviews/registry.ts'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
@@ -66,8 +65,9 @@ describe('tool-ring full chain (positive dual)', () => {
const registry = new ToolViewRegistry()
// Registration: I inferred from the factory, component proved ⊇ ToolViewProps & I.
const disposeGlobal = registry.register('bash', InjectedRow, {
inject: (b: SessionBinding): RowInjected => ({
useRuns: () => b.sessionId.length,
// Terminal channel form: the factory receives the session id only.
inject: (sessionId: SessionId): RowInjected => ({
useRuns: () => sessionId.length,
actions2: { rerun: () => {} },
}),
})
@@ -81,9 +81,7 @@ describe('tool-ring full chain (positive dual)', () => {
expect(global?.component).toBe(InjectedRow)
// Read face: I is erased to object, the factory reference survives; the
// outlet-side restoration is the budgeted cast (same boundary as slots).
const injected = (global?.inject as (b: SessionBinding) => RowInjected)(
{ sessionId: 'ab', session: { useSelector: undefined }, ctx: undefined },
)
const injected = (global?.inject as (sessionId: SessionId) => RowInjected)(sid('ab'))
expect(injected.useRuns()).toBe(2)
// Unknown tool → undefined (caller falls back to the generic card).
expect(registry.resolve('ghost-tool', sid('other'))).toBeUndefined()
@@ -69,6 +69,17 @@ describe('view-ring type-chain negatives (compile-time; body never runs)', () =>
chrome: { header: (props: ChromePropsOf<'vt-plain'> & { onlyPlain: true }) => null },
}
void mixed
// 6. Zero-renderSlot inference: the view ring declares no children, so
// view props carry no delegation face (the old hand-written
// ScopedSlots<never> empty surface is retired, not replaced).
const renderless = (props: ConvViewPropsOf<'vt-plain'>): ReactNode => {
// @ts-expect-error views receive no renderSlot — no sub-slot delegation
void props.renderSlot
// @ts-expect-error the legacy slots face is gone from view props
void props.slots
return null
}
void renderless
return null as ReactNode
}
expect(negatives).toBeTypeOf('function')
+1 -1
View File
@@ -2,7 +2,7 @@
Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. Contract: api-contracts v3 §5.
Slot declarations use the composed-props entry form (`owner` share, no full `props`): the exported OwnerShare contracts are `SidebarOwnerProps` / `ConvOwnerProps` / `DetailsOwnerProps` / `EmptyOwnerProps` — registrants reference them via `OwnerOf<'sidebar' | ...>` and compose their own injected share locally. No entry declares `children` (declaring it requires the registered component to carry the slots face): no P-I slot component delegates — `conversation.empty` is rendered by the shell's assembly closure, not handed down by ConversationRoot.
Slot declarations use the composed-props entry form (`owner` share, no full `props`): the exported OwnerShare contracts are `SidebarOwnerProps` / `ConvOwnerProps` / `DetailsOwnerProps` / `EmptyOwnerProps` — registrants reference them via `OwnerOf<'sidebar' | ...>` and compose their own injected share locally. No entry declares `children` (declaring it requires the registered component to carry the slots face — reserved for future business slots): delegation authority is the component-side whitelist, i.e. AppFrame's `ScopedSlots<FrameSlotKey>` face over sidebar/conversation/details/conversation.empty. Since the root-slot rework the frame itself registers into 'root' and renders those child slots at its own render sites; the shell only renders 'root'.
The export surface is the cross-package contract only: the AppFrame trio (+ `AppFrameProps`) consumed by the web shell's assembly, `LayoutService` with its store shapes (`NavState`/`PanelState`/`ViewId`), and the OwnerShare contracts. The concession-chain solver (`computeColumns`) and its geometry constants are package-internal; tests import them from `/src`.
@@ -1,43 +1,35 @@
/**
* Three-column shell frame. Owns the grid tracks (sidebar | center | details),
* the two drag handles (pointer capture + rAF throttle), and the concession
* chain (columns.ts). Column content arrives via props: `sidebar` is the
* sidebar slot render, `children` is the session area (the shell mounts
* SessionProvider there; its body renders {@link CenterColumn} and
* {@link DetailsColumn}, which land as grid items because neither the provider
* nor fragments emit DOM). Zero cordis imports — stores and actions are
* injected as props.
* Three-column shell frame, registered into the built-in 'root' slot (the web
* shell renders only 'root'). Owns the grid tracks (sidebar | center |
* 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.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-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 { PanelState } from './service.ts'
import type { createLayoutStore } from './stores.ts'
import css from './AppFrame.module.css'
/** AppFrame props: injected viewing-state hooks, stable width actions, column content. */
export interface AppFrameProps {
/** Selector hook over the sidebar panel store. */
useSidebar: SnapshotSelectorHook<PanelState>
/** Selector hook over the details panel store. */
useDetails: SnapshotSelectorHook<PanelState>
/** Persist a sidebar width preference (service clamps). */
setSidebarWidth: (px: number) => void
/** Persist a details width preference (service clamps). */
setDetailsWidth: (px: number) => void
/** Sidebar column content (shell: renderSlot('sidebar')). */
sidebar: ReactNode
/** Session area (shell: SessionProvider whose body renders CenterColumn + DetailsColumn). */
children?: ReactNode
}
/** Full composed props: runtime share + child-slot render share + store share (no business face). */
export type AppFrameProps =
& PropsRuntime<'root'>
& PropsRenderSlots<'sidebar' | 'conversation' | 'details' | 'conversation.empty'>
& PropsStore<ReturnType<typeof createLayoutStore>>
/** Center column grid item; rendered inside the session provider's body. */
export function CenterColumn(props: { children?: ReactNode }) {
/** Center column grid item (session-body building block). */
function CenterColumn(props: { children?: ReactNode }) {
return <div className={css.centerCol}>{props.children}</div>
}
/** Details column grid item; width 0 keeps the subtree mounted (never unmount on close). */
export function DetailsColumn(props: { children?: ReactNode }) {
function DetailsColumn(props: { children?: ReactNode }) {
return <div className={css.detailsCol}>{props.children}</div>
}
@@ -87,9 +79,8 @@ function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: num
}
/** The three-column frame (see module doc). */
export function AppFrame(props: AppFrameProps) {
const sidebar = props.useSidebar((s) => s)
const details = props.useDetails((s) => s)
export function AppFrame({ useStore, actions, renderSlot }: AppFrameProps) {
const panels = useStore((s) => s)
const frameRef = useRef<HTMLDivElement | null>(null)
const [viewport, setViewport] = useState(() => window.innerWidth)
@@ -113,7 +104,7 @@ export function AppFrame(props: AppFrameProps) {
}
}, [])
const cols = computeColumns(viewport, sidebar, details)
const cols = computeColumns(viewport, panels.sidebar, panels.details)
const colsRef = useRef(cols)
colsRef.current = cols
@@ -122,15 +113,14 @@ export function AppFrame(props: AppFrameProps) {
// it stays frozen for the whole gesture so dx deltas do not compound.
const sidebarBase = useRef(0)
const detailsBase = useRef(0)
const { setSidebarWidth, setDetailsWidth } = props
const onSidebarStart = useCallback(() => { sidebarBase.current = colsRef.current.sidebar }, [])
const onDetailsStart = useCallback(() => { detailsBase.current = colsRef.current.details }, [])
const onSidebarDrag = useCallback((dx: number) => {
setSidebarWidth(sidebarBase.current + dx)
}, [setSidebarWidth])
actions.setSidebar(sidebarBase.current + dx)
}, [actions])
const onDetailsDrag = useCallback((dx: number) => {
setDetailsWidth(detailsBase.current - dx)
}, [setDetailsWidth])
actions.setDetails(detailsBase.current - dx)
}, [actions])
return (
<div
@@ -140,8 +130,28 @@ export function AppFrame(props: AppFrameProps) {
data-sidebar-collapsed={cols.sidebar === 0 || undefined}
data-details-collapsed={cols.details === 0 || undefined}
>
<div className={css.sidebarCol}>{props.sidebar}</div>
{props.children}
<div className={css.sidebarCol}>
{/* Render-site slot call with live concession output: the sidebar
stays mounted at zero width (CSS hides it), and sees its rendered
state as owner params decided here, not precomputed upstream. */}
{renderSlot('sidebar', { collapsed: cols.sidebar === 0, width: cols.sidebar })}
</div>
<SessionProvider
empty={() => (
<>
<CenterColumn>{renderSlot('conversation.empty', {})}</CenterColumn>
<DetailsColumn />
</>
)}
>
{() => (
<>
{/* sessionId is a framework-standard prop on session slots — the owner passes nothing. */}
<CenterColumn>{renderSlot('conversation', {})}</CenterColumn>
<DetailsColumn>{renderSlot('details', {})}</DetailsColumn>
</>
)}
</SessionProvider>
{cols.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} />}
{cols.details > 0 && <DragHandle left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} />}
</div>
@@ -2,13 +2,11 @@
* Pure concession-chain column solver for the three-column AppFrame.
* Chain order is fixed by contract: keep center >= CENTER_MIN by shrinking
* details first, then sidebar, then auto-closing details (derived zero width —
* persisted open/width preferences are never rewritten, so widening the window
* persisted width preferences are never rewritten, so widening the window
* restores them). Center absorbs any remaining deficit as the last resort.
* Inputs are the layout store's plain width preferences (0 = closed).
*/
/** Panel viewing state consumed by the solver (mirrors LayoutService PanelState). */
export interface PanelInput { open: boolean; width: number }
/** Resolved widths for one frame; center may drop below CENTER_MIN only at the final fallback. */
export interface Columns { sidebar: number; center: number; details: number }
@@ -44,16 +42,16 @@ export function clampWidth(px: number, min: number, max: number): number {
* the output is a function of (viewport, preferences) only, so recovery on
* re-widening is automatic. After the auto-close step the details pressure is
* gone, so the sidebar returns to its preferred width when it fits.
* Preferences re-clamp here because they cross a durable boundary
* (localStorage rehydration may carry stale ranges).
* @param viewport - available frame width in px.
* @param sidebar - sidebar preference (open flag + persisted width).
* @param details - details preference (open flag + persisted width).
* @param sidebar - sidebar width preference in px (0 = closed).
* @param details - details width preference in px (0 = closed).
* @returns resolved widths; details 0 means visually closed (never unmounted).
*/
export function computeColumns(viewport: number, sidebar: PanelInput, details: PanelInput): Columns {
const want = (p: PanelInput, min: number, max: number): number =>
p.open ? clampWidth(p.width, min, max) : 0
const s0 = want(sidebar, SIDEBAR_MIN, SIDEBAR_MAX)
const d0 = want(details, DETAILS_MIN, DETAILS_MAX)
export function computeColumns(viewport: number, sidebar: number, details: number): Columns {
const s0 = sidebar === 0 ? 0 : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX)
const d0 = details === 0 ? 0 : clampWidth(details, DETAILS_MIN, DETAILS_MAX)
// Step 1: everything fits at preferred widths.
if (s0 + d0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0 - d0, details: d0 }
+62 -38
View File
@@ -1,23 +1,23 @@
/**
* Layout plugin, browser half: three-column AppFrame plus ctx.layout, the
* shell-level viewing-state authority (navigation + panel geometry).
* Contract: api-contracts v3 section 5. apply provides the service and
* defines the three top-level slots; frame components are exported for the
* web shell's assembly (the shell resolves this surface from the loader
* module table and closes the slots over its own scopedSlots).
* Layout plugin, browser half: one register() call contributes AppFrame into
* the runtime's built-in 'root' slot and, in the same breath, declares the
* four child slots (declaration = exclusive render authority), seats the
* layout store (panel geometry), and wires the panel-action service face.
* ctx.layout is the cross-plugin panel-action seam; navigation state lives
* with the runtime sessions service.
*/
import type { Context } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { PanelActions } from './service.ts'
import { AppFrame } from './AppFrame.tsx'
import { createLayoutStore } from './stores.ts'
import { LayoutService } from './service.ts'
// Contract surface only (export-convergence rule: cross-package consumers
// keep a symbol exported; test-only/package-internal symbols live off /src).
// AppFrame trio + AppFrameProps: consumed by the web shell's assembly closure.
// LayoutService: the ctx.layout service class (consumers type against it).
// PanelState rides AppFrameProps' hooks; NavState/ViewId are service-store
// shapes referenced through LayoutService's members.
export { AppFrame, CenterColumn, DetailsColumn, type AppFrameProps } from './AppFrame.tsx'
export { LayoutService, type NavState, type PanelState, type ViewId } from './service.ts'
// OwnerShare contracts below are the render-side halves registrants compose
// against; the frame components and the store factory are package-internal.
export { LayoutService } from './service.ts'
declare module 'cordis' {
interface Context {
@@ -27,11 +27,11 @@ declare module 'cordis' {
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
// The 'root' entry itself is the runtime's built-in slot (declared
// there); these four are the frame's children, declared by the same
// register() call that contributes AppFrame. Session slots carry no
// owner share: the framework injects sessionId as a standard prop.
'sidebar': { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps }
// children deliberately absent on every entry: the B-a validation layer
// gates COMPONENT delegation, and no P-I slot component delegates —
// conversation.empty is rendered by the shell's assembly closure, not
// handed down by ConversationRoot (its slots face is ScopedSlots<never>).
'conversation': { kind: 'single'; scope: 'session'; owner: ConvOwnerProps }
'details': { kind: 'single'; scope: 'session'; owner: DetailsOwnerProps }
'conversation.empty': { kind: 'single'; scope: 'root'; owner: EmptyOwnerProps }
@@ -40,43 +40,67 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
// OwnerShare contracts — the render-side share the slot owner supplies at
// renderSlot. Registrants IMPORT these and compose their full component props
// as OwnerOf<K> & StandardOf<K> & OwnInjected (reference, never re-typed).
// through the four-share intersection (PropsRuntime & PropsRenderSlots &
// PropsStore & I). Session owner shares stay literally empty: a phantom
// `sessionId?: never` would intersect with the framework's mandatory
// SessionStandardProps.sessionId and collapse the composed props to never —
// the anti-smuggling guard is mutually exclusive with standard injection, so
// the standard member's own type is the only guard on standard keys. Phantom
// members remain fine on keys the standards never claim (EmptyOwnerProps).
/** Sidebar owner share: the owner supplies nothing — everything arrives via inject. */
export interface SidebarOwnerProps { slots?: never }
/** Sidebar owner share: live column state from the frame's concession solve. */
export interface SidebarOwnerProps {
/** True when the concession chain rendered the column at zero width. */
collapsed: boolean
/** Rendered column width in px (0 when collapsed). */
width: number
}
/** Conversation owner share. */
export interface ConvOwnerProps { sessionId: SessionId }
/** Conversation owner share: empty — sessionId arrives as a framework-standard prop. */
export interface ConvOwnerProps {}
/** Details owner share. */
export interface DetailsOwnerProps { sessionId: SessionId }
/** Details owner share: empty — sessionId arrives as a framework-standard prop. */
export interface DetailsOwnerProps {}
/** Empty-state owner share (ui-conversation registers EmptyState here). */
export interface EmptyOwnerProps { slots?: never }
export interface EmptyOwnerProps { children?: never }
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
export const inject = ['slots']
/**
* Client plugin body: provide ctx.layout and define the three top-level slots.
* Client plugin body: provide ctx.layout, then one register() call — AppFrame
* into 'root' with the four child-slot declarations, the layout store seat,
* and the inject hook that hands the store's bound actions to the service.
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
const layout = new LayoutService(ctx)
export function apply(ctx: ClientContext): void {
const layout = new LayoutService()
ctx.effect(() => {
const disposeService = ctx.reflect.provide('layout', layout)
const disposeSidebar = ctx.slots.define('sidebar', { kind: 'single', scope: 'root' })
const disposeConversation = ctx.slots.define('conversation', { kind: 'single', scope: 'session' })
const disposeDetails = ctx.slots.define('details', { kind: 'single', scope: 'session' })
const disposeEmpty = ctx.slots.define('conversation.empty', { kind: 'single', scope: 'root' })
const disposeRegistration = ctx.slots.register({
name: 'root',
children: {
'sidebar': { kind: 'single', scope: 'root' },
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
// Exclusive store: the factory itself — the framework instantiates per
// entry and delivers useStore/actions to AppFrame as standard props.
store: createLayoutStore,
// No business face for the frame (I = {}): the hook's job is the
// assembly side effect wiring the entry's bound actions into the
// cross-plugin service seam.
inject: (actions: PanelActions) => {
layout.attachPanels(actions)
return {}
},
}, AppFrame)
return () => {
disposeEmpty()
disposeDetails()
disposeConversation()
disposeSidebar()
disposeRegistration()
// provide()'s disposer settles asynchronously; teardown is synchronous fire-and-forget.
void disposeService()
layout.dispose()
}
}, 'ui-layout: service + slot definitions')
}, 'ui-layout: service + root registration')
}
+32 -110
View File
@@ -1,132 +1,54 @@
/**
* LayoutService implementation: the shell-level viewing-state authority.
* Four persisted stores (nav + two panels); actions clamp and validate. The
* concession chain lives in columns.ts and never writes back into these
* stores — persisted preferences survive window shrinking.
* LayoutService: the cross-plugin panel-action face behind ctx.layout.
* Panel geometry itself lives in the root entry's layout store (stores.ts);
* the current-session selection lives with the runtime sessions service, and
* the per-session active view dissolved into ui-conversation's session store
* (its only consumer). What remains here is the seam other plugins'
* apply worlds reach for panel transitions (sidebar toggle from ui-sidebar,
* details open/close from ui-conversation) — writes stay inside the store's
* declared action set, delivered as the registration's bound actions.
*/
import type { Context } from 'cordis'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import {
clampWidth, DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN,
SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
} from './columns.ts'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { createLayoutStore } from './stores.ts'
/** Active conversation view id (keys merged into ConversationViewMap by ui-conversation). */
export type ViewId = string
/** The layout store's bound action set (framework-baked, draft params peeled). */
export type PanelActions = BoundActions<ReturnType<typeof createLayoutStore>>
/** Navigation state: selected session and per-session active view. */
export interface NavState { sessionId?: SessionId; viewFor: Record<SessionId, ViewId> }
/** Panel viewing state: open flag plus persisted width. */
export interface PanelState { open: boolean; width: number }
/** Shell-level viewing-state authority (zustand + persist). */
/** Cross-plugin panel-action face (ctx.layout). */
export class LayoutService {
/** Navigation state store. */
readonly current: SnapshotStore<NavState>
/** Sidebar panel store (default 300, clamp [240, 420]). */
readonly sidebar: SnapshotStore<PanelState>
/** Details panel store (default 360, clamp [300, 520]; P-I global, not per-session). */
readonly details: SnapshotStore<PanelState>
#sessions: SessionsService
#unprune: () => void
#panels: PanelActions | undefined
/**
* @param ctx - root context (resolves the sessions service for open validation and list pruning).
* Adopt the root entry's bound store actions. Called from the root
* registration's inject hook (a sanctioned assembly side effect), so the
* face is live from the entry's first render; on entry re-register the
* fresh actions overwrite the stale set.
* @param actions - bound actions of the entry's layout store instance.
*/
constructor(ctx: Context) {
// ctx.get instead of ctx.sessions: the typed Context merge is suspended
// while the client/host `sessions` declaration collision awaits
// arbitration (see the runtime package's Context merge note).
const sessions = ctx.get('sessions')
if (sessions === undefined) throw new Error('layout: sessions service unavailable')
this.#sessions = sessions
this.current = createSnapshotStore<NavState>(
{ viewFor: {} },
{ persist: { name: 'dsh.layout.nav' } })
this.sidebar = createSnapshotStore<PanelState>(
{ open: true, width: SIDEBAR_DEFAULT },
{ persist: { name: 'dsh.layout.sidebar' } })
this.details = createSnapshotStore<PanelState>(
{ open: false, width: DETAILS_DEFAULT },
{ persist: { name: 'dsh.layout.details' } })
// Prune is one-directional: list removals clear keyed viewing state, and a
// selection pointing at a removed session falls back to the empty state.
this.#unprune = sessions.list.subscribe(() => { this.#prune() })
attachPanels(actions: PanelActions): void {
this.#panels = actions
}
/** Drop the sessions.list subscription (plugin teardown). */
dispose(): void {
this.#unprune()
}
#prune(): void {
const byId = this.#sessions.list.getSnapshot().byId
const nav = this.current.getSnapshot()
// Object.keys erases the branded key type; these entries were written with SessionId keys.
const viewKeys = Object.keys(nav.viewFor) as SessionId[]
const staleView = viewKeys.some(id => byId[id] === undefined)
const staleCurrent = nav.sessionId !== undefined && byId[nav.sessionId] === undefined
if (!staleView && !staleCurrent) return
this.current.update((draft) => {
// Rebuild instead of dynamic delete: viewFor is a plain keyed record and
// the survivors are the entries whose session still exists.
draft.viewFor = Object.fromEntries(
Object.entries(draft.viewFor).filter(([id]) => byId[id as SessionId] !== undefined))
if (draft.sessionId !== undefined && byId[draft.sessionId] === undefined) delete draft.sessionId
})
}
/**
* Select a session. Unknown ids fail loud instead of navigating nowhere.
* @param id - session id (must exist in sessions.list).
*/
open(id: SessionId): void {
if (this.#sessions.list.getSnapshot().byId[id] === undefined) {
throw new Error(`layout.open: unknown session ${id}`)
}
this.current.update((draft) => { draft.sessionId = id })
}
/**
* Activate a view for a session.
* @param sessionId - session id.
* @param view - view id.
*/
openView(sessionId: SessionId, view: ViewId): void {
this.current.update((draft) => { draft.viewFor[sessionId] = view })
}
/** Toggle the sidebar panel. */
/** Toggle the sidebar panel (closed ⟷ contract default width). */
toggleSidebar(): void {
this.sidebar.update((draft) => { draft.open = !draft.open })
this.#require().toggleSidebar()
}
/**
* Set the sidebar width (clamped to [240, 420]).
* @param px - width in pixels.
*/
setSidebarWidth(px: number): void {
this.sidebar.update((draft) => { draft.width = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) })
}
/** Open the details panel. */
/** Open the details panel (no-op when already open). */
openDetails(): void {
this.details.update((draft) => { draft.open = true })
this.#require().openDetails()
}
/** Close the details panel. */
closeDetails(): void {
this.details.update((draft) => { draft.open = false })
this.#require().closeDetails()
}
/**
* Set the details width (clamped to [300, 520]).
* @param px - width in pixels.
*/
setDetailsWidth(px: number): void {
this.details.update((draft) => { draft.width = clampWidth(px, DETAILS_MIN, DETAILS_MAX) })
#require(): PanelActions {
// Callers are UI gestures, which cannot fire before the root entry
// rendered (the inject hook runs in its first render) — reaching this
// unwired is a boot-order bug, not a race to tolerate.
if (this.#panels === undefined) throw new Error('layout: panel actions not wired (root entry not mounted)')
return this.#panels
}
}
@@ -0,0 +1,36 @@
/**
* The root entry's layout store: panel geometry as plain widths in px
* (0 = closed), persisted across reloads. Module level exports the factory
* only — a module-level handle would pin the store's identity in the module
* cache (a de-facto singleton surviving plugin reloads). register() receives
* the factory (exclusive use: the framework instantiates per entry), AppFrame
* 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 {
clampWidth, DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN,
SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
} from './columns.ts'
/**
* Create the layout panel store handle. The persisted preference IS the
* width, so closing a panel forgets its drag width — reopening restores the
* contract default. Actions are the complete write set: drag writes clamp
* into the panel's contract range and never cross the open/closed line;
* open/close transitions write 0 / the default explicitly.
* @returns the store handle (spec + type + identity + factory in one).
*/
export function createLayoutStore() {
return defineStore({
init: () => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }),
persist: 'dsh.layout.panels',
actions: {
setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) },
setDetails: (d, px: number) => { d.details = clampWidth(px, DETAILS_MIN, DETAILS_MAX) },
toggleSidebar: (d) => { d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0 },
openDetails: (d) => { if (d.details === 0) d.details = DETAILS_DEFAULT },
closeDetails: (d) => { d.details = 0 },
},
})
}
@@ -1,16 +1,35 @@
// @vitest-environment jsdom
/**
* AppFrame interaction spec: drag sequences (pointer capture + rAF flush),
* concession response to viewport change, details stays mounted at zero
* width. jsdom has no layout engine, so the frame width comes from a mocked
* getBoundingClientRect and resizes are driven through the ResizeObserver
* stub; assertions read the inline grid template.
* AppFrame interaction spec under the four-share props form: real layout
* store instance (createLayoutStore().create() — the test-sanctioned engine
* path), a recording renderSlot stub, and a render-prop SessionProvider stub
* (the real one is framework-wired to the renderer host; its own behavior is
* web-react's spec territory). Drag sequences (pointer capture + rAF flush),
* concession response to viewport change, and details staying mounted at
* zero width are the preserved behavior assertions. jsdom has no layout
* engine, so the frame width comes from a mocked getBoundingClientRect and
* resizes are driven through the ResizeObserver stub.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { AppFrame, CenterColumn, DetailsColumn, type PanelState } from '@deepseek-ai/dsh-client-ui-layout/client'
import { clampWidth } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
import type { ReactNode } 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 }))
vi.mock('@deepseek-ai/dsh-client-web-react', async (importOriginal) => {
const mod = await importOriginal<object>()
return {
...mod,
// Render-prop contract stub: session mode runs children(id), empty mode
// runs the empty branch — the frame must work against exactly this shape.
SessionProvider: ({ children, empty }: { children: (id: string) => ReactNode; empty?: () => ReactNode }) =>
sessionMode.current ? <>{children('s-test')}</> : <>{empty?.() ?? null}</>,
}
})
/** Observer stub: captures the callback so tests can fire resizes manually. */
let fireResize: (() => void) | null = null
@@ -26,22 +45,27 @@ let frameWidth = 1920
function mountFrame() {
window.innerWidth = frameWidth // first-render viewport source before the observer fires
const sidebar = createSnapshotStore<PanelState>({ open: true, width: 300 })
const details = createSnapshotStore<PanelState>({ open: true, width: 360 })
const instance = createLayoutStore().create()
instance.actions.openDetails() // seed: sidebar at default 300, details open at default 360
const slotCalls: { key: string; props: unknown }[] = []
const renderSlot = ((key: string, owner: object) => {
slotCalls.push({ key, props: owner })
if (key === 'sidebar') return <div data-testid="sidebar-content" />
if (key === 'conversation') return <div data-testid="center-content" />
if (key === 'details') return <div data-testid="details-content" />
return <div data-testid="empty-content" />
}) as AppFrameProps['renderSlot']
const useSessions = ((sel: (s: unknown) => unknown) => sel({ ids: [], byId: {} })) as never
const utils = render(
<AppFrame
useSidebar={sidebar.useSelector}
useDetails={details.useSelector}
setSidebarWidth={(px) => { sidebar.update((d) => { d.width = clampWidth(px, 240, 420) }) }}
setDetailsWidth={(px) => { details.update((d) => { d.width = clampWidth(px, 300, 520) }) }}
sidebar={<div data-testid="sidebar-content" />}
>
<CenterColumn><div data-testid="center-content" /></CenterColumn>
<DetailsColumn><div data-testid="details-content" /></DetailsColumn>
</AppFrame>,
useStore={instance.useSelector}
actions={instance.actions}
renderSlot={renderSlot}
useSessions={useSessions}
/>,
)
const frame = utils.container.firstElementChild as HTMLElement
return { sidebar, details, frame, ...utils }
return { instance, frame, slotCalls, ...utils }
}
function tracks(frame: HTMLElement): number[] {
@@ -61,6 +85,8 @@ function drag(handle: Element, fromX: number, toX: number): void {
beforeEach(() => {
frameWidth = 1920
sessionMode.current = true
localStorage.clear() // the layout store persists; instances must not bleed across tests
vi.useFakeTimers()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number)
@@ -83,11 +109,37 @@ afterEach(() => {
})
describe('AppFrame', () => {
it('renders three tracks from panel state', () => {
it('renders three tracks from store state', () => {
const { frame } = mountFrame()
expect(tracks(frame)).toEqual([300, 360])
})
it('renders the session pair with empty owner shares (sessionId is framework-standard)', () => {
const { slotCalls, getByTestId } = mountFrame()
expect(getByTestId('center-content')).toBeTruthy()
expect(getByTestId('details-content')).toBeTruthy()
const keys = slotCalls.map((c) => c.key)
expect(keys).toContain('conversation')
expect(keys).toContain('details')
expect(keys).not.toContain('conversation.empty')
expect(slotCalls.find((c) => c.key === 'conversation')!.props).toEqual({})
expect(slotCalls.find((c) => c.key === 'details')!.props).toEqual({})
})
it('renders the empty branch through conversation.empty when no session is current', () => {
sessionMode.current = false
const { slotCalls, getByTestId, queryByTestId } = mountFrame()
expect(getByTestId('empty-content')).toBeTruthy()
expect(queryByTestId('center-content')).toBeNull()
expect(slotCalls.map((c) => c.key)).toContain('conversation.empty')
expect(slotCalls.map((c) => c.key)).not.toContain('conversation')
})
it('sidebar slot receives live concession output as owner props', () => {
const { slotCalls } = mountFrame()
expect(slotCalls.find((c) => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 300 })
})
it('sidebar drag widens through rAF-batched pointer moves', () => {
const { frame } = mountFrame()
const handles = frame.querySelectorAll('[class*="handle"]')
@@ -104,16 +156,16 @@ describe('AppFrame', () => {
it('drag base is the rendered (concession-clamped) width, not the preference', () => {
frameWidth = 1250 // step-2 squeeze: details renders 310 while preference is 360
const { frame, details } = mountFrame()
const { frame, instance } = mountFrame()
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(details.getSnapshot().width).toBe(300)
expect(instance.store.getSnapshot().details).toBe(300)
})
it('details column stays mounted at zero width', () => {
const { frame, details, getByTestId } = mountFrame()
act(() => { details.update((d) => { d.open = false }) })
const { frame, instance, getByTestId } = mountFrame()
act(() => { instance.actions.closeDetails() })
expect(tracks(frame)).toEqual([300, 0])
expect(getByTestId('details-content')).toBeTruthy()
expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
@@ -130,31 +182,31 @@ describe('AppFrame', () => {
})
it('drag handles disappear for collapsed columns', () => {
const { frame, details, sidebar } = mountFrame()
const { frame, instance } = mountFrame()
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(2)
act(() => { details.update((d) => { d.open = false }) })
act(() => { instance.actions.closeDetails() })
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
act(() => { sidebar.update((d) => { d.open = false }) })
act(() => { instance.actions.toggleSidebar() })
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(0)
})
})
describe('AppFrame — guard branches', () => {
it('pointer moves without capture are ignored (no width write)', () => {
const { frame, sidebar } = mountFrame()
const { frame, instance } = mountFrame()
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
const before = sidebar.getSnapshot().width
const before = instance.store.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(sidebar.getSnapshot().width).toBe(before)
expect(instance.store.getSnapshot().sidebar).toBe(before)
})
it('two moves inside one frame coalesce through the pending rAF', () => {
const { frame, sidebar } = mountFrame()
const { frame, instance } = mountFrame()
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
act(() => {
@@ -165,11 +217,11 @@ describe('AppFrame — guard branches', () => {
vi.advanceTimersByTime(20)
})
act(() => { handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 340, bubbles: true })) })
expect(sidebar.getSnapshot().width).toBe(340)
expect(instance.store.getSnapshot().sidebar).toBe(340)
})
it('pointerup with a pending rAF cancels it and commits the final position', () => {
const { frame, sidebar } = mountFrame()
const { frame, instance } = mountFrame()
const handle = frame.querySelectorAll('[class*="handle"]')[0]!
act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) })
act(() => {
@@ -177,7 +229,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(sidebar.getSnapshot().width).toBe(360)
expect(instance.store.getSnapshot().sidebar).toBe(360)
})
it('zero-width resize reports are ignored (display:none window)', () => {
@@ -199,7 +251,7 @@ describe('AppFrame — unmount with an in-flight resize frame', () => {
expect(() => { vi.advanceTimersByTime(20) }).not.toThrow()
})
it('double resize inside one frame rides the pending rAF (?"?= guard)', () => {
it('double resize inside one frame rides the pending rAF (??= guard)', () => {
const { frame } = mountFrame()
frameWidth = 1250
act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) })
+14 -14
View File
@@ -1,15 +1,14 @@
// @vitest-environment jsdom
// Client apply wiring: ctx.layout provided, the four layout-owned slots
// defined, teardown cascades (service unprovided + slot specs removed + list
// subscription dropped). Node half and the invariant companion ride along —
// they are one-line surfaces the aggregate coverage gate still requires
// exercised.
// Client apply wiring under the terminal register form: ctx.layout provided,
// ONE register() call declares the four child slots + seats the store factory
// + wires the panel actions through the inject hook; teardown cascades
// (service unprovided + declarations gone + registration cleared). Node half
// and the invariant companion ride along — one-line surfaces the aggregate
// coverage gate still requires exercised.
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout'
import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant'
@@ -18,8 +17,6 @@ async function bench() {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
ctx.provide('sessions', { list })
return { ctx, slots: ctx.get('slots') as SlotsService }
}
@@ -28,28 +25,31 @@ describe('ui-layout client apply', () => {
expect(inject).toContain('slots')
})
it('provides ctx.layout and defines the four layout-owned slots', async () => {
it('provides ctx.layout and registers AppFrame into root with the four child declarations', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: ['slots'], apply })
await fiber.await()
expect(ctx.get('layout')).toBeInstanceOf(LayoutService)
// The one register() call occupied 'root'…
expect(slots.entries('root')).toHaveLength(1)
// …and declared the four children in the ledger.
expect(slots.spec('sidebar')).toEqual({ kind: 'single', scope: 'root' })
expect(slots.spec('conversation')).toEqual({ kind: 'single', scope: 'session' })
expect(slots.spec('details')).toEqual({ kind: 'single', scope: 'session' })
expect(slots.spec('conversation.empty')).toEqual({ kind: 'single', scope: 'root' })
})
it('teardown unwinds service, slot specs, and the prune subscription', async () => {
it('teardown unwinds the service, the root registration, and the child declarations', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: ['slots'], apply })
await fiber.await()
const layout = ctx.get('layout') as LayoutService
const disposeSpy = vi.spyOn(layout, 'dispose')
await fiber.dispose()
expect(ctx.get('layout')).toBeUndefined()
expect(slots.entries('root')).toHaveLength(0)
expect(slots.spec('sidebar')).toBeUndefined()
expect(slots.spec('conversation.empty')).toBeUndefined()
expect(disposeSpy).toHaveBeenCalledTimes(1)
// The built-in root declaration survives entry teardown (runtime-owned).
expect(slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
})
})
@@ -4,8 +4,9 @@ import {
DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_DEFAULT, SIDEBAR_MIN,
} from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
const open = (width: number) => ({ open: true, width })
const closed = (width: number) => ({ open: false, width })
// Numeric preference form (0 = closed); helpers keep the scenario names readable.
const open = (width: number) => width
const closed = (_width: number) => 0
describe('clampWidth', () => {
it('clamps into the range and rounds', () => {
@@ -0,0 +1,73 @@
// @vitest-environment jsdom
/**
* createLayoutStore unit account: init shape, the action write set (clamp
* inside actions), and the persist key round-trip over jsdom localStorage.
* Uses the test-sanctioned path: factory self-call + .create() gives the
* real engine instance (same create path as production).
*/
import { beforeEach, describe, expect, it } from 'vitest'
import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts'
import {
DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN,
SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
} from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
const PERSIST_KEY = 'dsh.layout.panels'
beforeEach(() => { localStorage.clear() })
describe('createLayoutStore', () => {
it('initializes with sidebar open at default and details closed', () => {
const { store } = createLayoutStore().create()
expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0 })
})
it('each create() is an independent instance (factory is not a singleton)', () => {
const a = createLayoutStore().create()
const b = createLayoutStore().create()
a.actions.setSidebar(400)
expect(b.store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT)
})
it('setSidebar/setDetails clamp into the contract ranges', () => {
const { store, actions } = createLayoutStore().create()
actions.setSidebar(1)
expect(store.getSnapshot().sidebar).toBe(SIDEBAR_MIN)
actions.setSidebar(9999)
expect(store.getSnapshot().sidebar).toBe(SIDEBAR_MAX)
actions.setDetails(1)
expect(store.getSnapshot().details).toBe(DETAILS_MIN)
actions.setDetails(9999)
expect(store.getSnapshot().details).toBe(DETAILS_MAX)
})
it('toggleSidebar flips closed <-> contract default (drag width forgotten)', () => {
const { store, actions } = createLayoutStore().create()
actions.setSidebar(400)
actions.toggleSidebar()
expect(store.getSnapshot().sidebar).toBe(0)
actions.toggleSidebar()
expect(store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT)
})
it('openDetails is a no-op when already open; closeDetails zeroes', () => {
const { store, actions } = createLayoutStore().create()
actions.openDetails()
expect(store.getSnapshot().details).toBe(DETAILS_DEFAULT)
actions.setDetails(500)
actions.openDetails()
expect(store.getSnapshot().details).toBe(500)
actions.closeDetails()
expect(store.getSnapshot().details).toBe(0)
})
it('persists under dsh.layout.panels and rehydrates on the next create', () => {
const first = createLayoutStore().create()
first.actions.setSidebar(320)
first.actions.openDetails()
expect(JSON.parse(localStorage.getItem(PERSIST_KEY) ?? '{}')).toEqual({ sidebar: 320, details: DETAILS_DEFAULT })
const second = createLayoutStore().create()
expect(second.store.getSnapshot()).toEqual({ sidebar: 320, details: DETAILS_DEFAULT })
})
})
+43 -125
View File
@@ -1,139 +1,57 @@
// @vitest-environment jsdom
/**
* LayoutService over the real snapshot-store engine (persist rides jsdom
* localStorage). ctx is faked down to the one surface the service reads:
* ctx.sessions.list as a real store, so prune subscriptions are exercised
* for real.
* LayoutService behavior: the cross-plugin panel-action face. Geometry
* lives in the entry store (layout-store.spec.ts) — here we assert the
* delegation seam: attachPanels wiring, the three actions forwarding, the
* unwired fail-loud, and re-attach overwriting a stale action set.
*/
import { beforeEach, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
import { DETAILS_DEFAULT, SIDEBAR_DEFAULT } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
import { describe, expect, it, vi } from 'vitest'
import { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/src/client/service.ts'
import type { PanelActions } from '@deepseek-ai/dsh-client-ui-layout/src/client/service.ts'
function makeCtx() {
const list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
// The service resolves sessions via ctx.get (typed merge suspended, see service).
const ctx = { get: (name: string) => (name === 'sessions' ? { list } : undefined) } as unknown as Context
return { ctx, list }
function fakePanels(): PanelActions {
return {
setSidebar: vi.fn(),
setDetails: vi.fn(),
toggleSidebar: vi.fn(),
openDetails: vi.fn(),
closeDetails: vi.fn(),
}
}
/** Test-side brand: specs mint ids the wire would normally brand. */
const sid = (s: string): SessionId => s as SessionId
const summary = (id: SessionId) => ({ id, title: id as string, running: false, updatedAt: 1 })
beforeEach(() => { localStorage.clear() })
describe('LayoutService', () => {
it('defaults: sidebar open 300, details closed 360, empty nav', () => {
const svc = new LayoutService(makeCtx().ctx)
expect(svc.sidebar.getSnapshot()).toEqual({ open: true, width: SIDEBAR_DEFAULT })
expect(svc.details.getSnapshot()).toEqual({ open: false, width: DETAILS_DEFAULT })
expect(svc.current.getSnapshot()).toEqual({ viewFor: {} })
svc.dispose()
it('forwards the three panel actions to the attached set', () => {
const service = new LayoutService()
const panels = fakePanels()
service.attachPanels(panels)
service.toggleSidebar()
service.openDetails()
service.closeDetails()
expect(panels.toggleSidebar).toHaveBeenCalledTimes(1)
expect(panels.openDetails).toHaveBeenCalledTimes(1)
expect(panels.closeDetails).toHaveBeenCalledTimes(1)
expect(panels.setSidebar).not.toHaveBeenCalled()
expect(panels.setDetails).not.toHaveBeenCalled()
})
it('open validates against sessions.list and selects', () => {
const { ctx, list } = makeCtx()
const svc = new LayoutService(ctx)
expect(() => { svc.open(sid('nope')) }).toThrow(/unknown session/)
list.update((d) => { d.ids.push(sid('s1')); d.byId[sid('s1')] = summary(sid('s1')) })
svc.open(sid('s1'))
expect(svc.current.getSnapshot().sessionId).toBe('s1')
svc.dispose()
it('fails loud before the root entry wired its actions', () => {
const service = new LayoutService()
expect(() => { service.toggleSidebar() }).toThrow(/panel actions not wired/)
expect(() => { service.openDetails() }).toThrow(/panel actions not wired/)
expect(() => { service.closeDetails() }).toThrow(/panel actions not wired/)
})
it('width setters clamp into contract ranges', () => {
const svc = new LayoutService(makeCtx().ctx)
svc.setSidebarWidth(10)
expect(svc.sidebar.getSnapshot().width).toBe(240)
svc.setSidebarWidth(10_000)
expect(svc.sidebar.getSnapshot().width).toBe(420)
svc.setDetailsWidth(10)
expect(svc.details.getSnapshot().width).toBe(300)
svc.setDetailsWidth(10_000)
expect(svc.details.getSnapshot().width).toBe(520)
svc.dispose()
})
it('re-attach overwrites the stale action set (entry re-register)', () => {
const service = new LayoutService()
const stale = fakePanels()
const fresh = fakePanels()
service.attachPanels(stale)
service.attachPanels(fresh)
it('toggle and open/close flip flags without touching widths', () => {
const svc = new LayoutService(makeCtx().ctx)
svc.toggleSidebar()
expect(svc.sidebar.getSnapshot()).toEqual({ open: false, width: SIDEBAR_DEFAULT })
svc.openDetails()
expect(svc.details.getSnapshot().open).toBe(true)
svc.closeDetails()
expect(svc.details.getSnapshot().open).toBe(false)
svc.dispose()
})
service.toggleSidebar()
it('prune clears viewFor entries and the current selection of removed sessions', () => {
const { ctx, list } = makeCtx()
const svc = new LayoutService(ctx)
list.update((d) => {
d.ids.push(sid('s1'), sid('s2'))
d.byId[sid('s1')] = summary(sid('s1'))
d.byId[sid('s2')] = summary(sid('s2'))
})
svc.open(sid('s1'))
svc.openView(sid('s1'), 'chat')
svc.openView(sid('s2'), 'chat')
list.update((d) => { d.ids = [sid('s2')]; d.byId = { [sid('s2')]: d.byId[sid('s2')]! } })
expect(svc.current.getSnapshot().sessionId).toBeUndefined()
expect(svc.current.getSnapshot().viewFor).toEqual({ s2: 'chat' })
svc.dispose()
})
it('prune leaves untouched state alone (no gratuitous store writes)', () => {
const { ctx, list } = makeCtx()
const svc = new LayoutService(ctx)
list.update((d) => { d.ids.push(sid('s1')); d.byId[sid('s1')] = summary(sid('s1')) })
svc.open(sid('s1'))
const before = svc.current.getSnapshot()
list.update((d) => { d.byId[sid('s1')] = { ...d.byId[sid('s1')]!, title: 'renamed' } })
expect(svc.current.getSnapshot()).toBe(before)
svc.dispose()
})
it('persists panel state and nav across instances (fresh service, same storage)', () => {
const first = new LayoutService(makeCtx().ctx)
first.setSidebarWidth(320)
first.openDetails()
first.dispose()
const second = new LayoutService(makeCtx().ctx)
expect(second.sidebar.getSnapshot().width).toBe(320)
expect(second.details.getSnapshot().open).toBe(true)
second.dispose()
})
it('dispose stops pruning', () => {
const { ctx, list } = makeCtx()
const svc = new LayoutService(ctx)
list.update((d) => { d.ids.push(sid('s1')); d.byId[sid('s1')] = summary(sid('s1')) })
svc.open(sid('s1'))
svc.dispose()
list.update((d) => { d.ids = []; d.byId = {} })
expect(svc.current.getSnapshot().sessionId).toBe('s1')
})
})
describe('LayoutService — construction and prune edge branches', () => {
it('throws loud when the sessions service is absent', () => {
const bare = { get: () => undefined } as unknown as Context
expect(() => new LayoutService(bare)).toThrow(/sessions service unavailable/)
})
it('prunes stale viewFor while the current selection stays valid', () => {
// Covers the prune branch where staleView holds but staleCurrent does not.
const { ctx, list } = makeCtx()
const svc = new LayoutService(ctx)
list.update((d) => { d.ids.push(sid('s1'), sid('s2')); d.byId[sid('s1')] = summary(sid('s1')); d.byId[sid('s2')] = summary(sid('s2')) })
svc.open(sid('s1'))
svc.openView(sid('s2'), 'chat')
list.update((d) => { d.ids = [sid('s1')]; d.byId = { [sid('s1')]: d.byId[sid('s1')]! } })
expect(svc.current.getSnapshot().sessionId).toBe('s1')
expect(svc.current.getSnapshot().viewFor).toEqual({})
svc.dispose()
expect(stale.toggleSidebar).not.toHaveBeenCalled()
expect(fresh.toggleSidebar).toHaveBeenCalledTimes(1)
})
})
+5 -3
View File
@@ -1,10 +1,12 @@
# @deepseek-ai/dsh-client-ui-sidebar
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Contract: api-contracts v3 §6.
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — tree hook, current-session hook, actions) and `SidebarRootComponentProps = OwnerOf<'sidebar'> & SidebarRootInjected` (the owner share referenced from ui-layout's slot declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory binds layout/sessions off `RootBinding<ClientContext>`.
`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx.
The `/client` export surface is the plugin body (`apply`/`inject`) plus the contract types only — SidebarRoot, the row components, and the tree/store implementation are internal (the slot registration closes over them; tests import src paths directly).
There is no plugin store: rows derive in the component (`useMemo` over the `useSessions` snapshot + local expansion/search state) through the pure `deriveRows` in `tree.ts`.
The `/client` export surface is the plugin body (`apply`/`inject`) plus the contract types only — SidebarRoot, the row components, and the tree derivation are internal (the slot registration closes over them; tests import src paths directly).
## Model Experience
@@ -1,11 +1,12 @@
/**
* SidebarRoot (figma 133:7629): logo row + collapse, New Session, search,
* WorkSpace section header with the group-by menu, session tree list,
* Settings foot. Pure presentational — data and actions arrive through the
* inject surface; the tree store is subscribed via useTree, never derived in
* render.
* Settings foot. Pure presentational — the session list arrives through the
* standard useSessions hook, viewing state (expansion, search) is local
* component state, and rows are derived in render via useMemo (slot design
* section 6: derived data is a pure function, no materializing store).
*/
import { Fragment, useState } from 'react'
import { Fragment, useMemo, useState } from 'react'
import clsx from 'clsx'
import {
FishLogo,
@@ -14,6 +15,7 @@ import {
Menu,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarRootComponentProps } from './contract/slots.ts'
import { deriveRows } from './tree.ts'
import { ProjectRowItem, SessionRowItem } from './Rows.tsx'
import css from './SidebarRoot.module.css'
@@ -24,16 +26,28 @@ const GROUP_BY_ITEMS = [
{ id: 'status', label: 'Status', disabled: true },
]
/** Immutable membership toggle for the local expansion arrays. */
function toggled(list: readonly string[], key: string): string[] {
return list.includes(key) ? list.filter((k) => k !== key) : [...list, key]
}
/**
* Render the sidebar column.
* @param props - composed slot props (owner share + injected surface, contract/slots.ts).
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
* @returns the sidebar element tree.
*/
export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootComponentProps) {
const rows = useTree((s) => s.rows)
const query = useTree((s) => s.query)
const groupBy = useTree((s) => s.groupBy)
const current = useCurrent()
export function SidebarRoot({ useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) {
const list = useSessions((s) => s)
// Wave-2 seam: row highlight expects `current` on the sessions list
// snapshot (sessions.current lives with the runtime sessions service).
const current = useSessions((s) => s.current)
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
const [query, setQuery] = useState('')
const rows = useMemo(
() => deriveRows(list, { expandedProjects, expandedSessions, query }),
[list, expandedProjects, expandedSessions, query],
)
const [menuOpen, setMenuOpen] = useState(false)
const now = Date.now()
@@ -60,13 +74,13 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
type="button"
className={css.iconButton}
aria-label="Collapse sidebar"
onClick={() => { actions.toggleSidebar() }}
onClick={() => { onToggleSidebar() }}
>
<IconPanelLeftOutline16 />
</button>
</div>
<button type="button" className={css.newSession} onClick={() => { actions.create() }}>
<button type="button" className={css.newSession} onClick={() => { onCreate() }}>
<IconNewChatOutline16 size={14} />
New Session
</button>
@@ -79,7 +93,7 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={GROUP_BY_ITEMS}
selectedId={groupBy}
selectedId="workspace"
onSelect={() => { setMenuOpen(false) }}
align="end"
anchor={(
@@ -97,7 +111,7 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
type="button"
className={css.iconButton}
aria-label="New workspace"
onClick={() => { actions.create() }}
onClick={() => { onCreate() }}
>
<IconProjectAddOutline16 />
</button>
@@ -110,14 +124,14 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
type="text"
placeholder="Search name, keywords..."
value={query}
onChange={(e) => { tree.setQuery(e.target.value) }}
onChange={(e) => { setQuery(e.target.value) }}
/>
{query !== '' && (
<button
type="button"
className={css.clearButton}
aria-label="Clear search"
onClick={() => { tree.setQuery('') }}
onClick={() => { setQuery('') }}
>
<IconCloseFill14 />
</button>
@@ -136,8 +150,8 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
<ProjectRowItem
row={row}
active={row.key === activeGroup}
onToggle={() => { tree.toggleProject(row.key) }}
onCreate={() => { actions.create(row.cwd) }}
onToggle={() => { setExpandedProjects((l) => toggled(l, row.key)) }}
onCreate={() => { onCreate(row.cwd) }}
/>
</Fragment>
)
@@ -147,8 +161,8 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
row={row}
selected={row.id === current}
now={now}
onOpen={() => { actions.open(row.id) }}
onToggle={() => { tree.toggleSession(row.id) }}
onOpen={() => { onOpen(row.id) }}
onToggle={() => { setExpandedSessions((l) => toggled(l, row.id)) }}
/>
))}
</div>
@@ -1,49 +1,42 @@
/**
* Sidebar slot contract: the registrant-side props composition for the
* layout-owned `sidebar` slot. The own injected share is declared here (a
* share's type lives with whoever wires it); the owner share is referenced
* off ui-layout's slot declaration through OwnerOf, never re-stated. Single
* domain — this is the package's whole contract surface.
* share's type lives with whoever wires it); the runtime share — owner
* props {collapsed,width} plus the standard useSessions hook — is
* PropsRuntime<'sidebar'>, resolved off ui-layout's SlotMap declaration and
* never re-stated. Single domain — this is the package's whole contract
* surface.
*/
import type { OwnerOf } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every
// program that sees this contract, so OwnerOf<'sidebar'> resolves.
// program that sees this contract, so PropsRuntime<'sidebar'> resolves.
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarTreeState } from '../store.ts'
/** Cross-plugin actions bound in apply (layout / sessions services). */
export interface SidebarActions {
open(id: SessionId): void
create(cwd?: string): void
toggleSidebar(): void
}
/** Plugin-owned tree viewing-state actions (tree store mutators). */
export interface SidebarTreeActions {
toggleProject(key: string): void
toggleSession(id: SessionId): void
setQuery(query: string): void
}
/**
* Registrant-private injected share (arrives via the register inject
* factory). A type alias, not an interface: the alias carries an implicit
* index signature, so the factory's return crosses the registry's
* `Record<string, unknown>` boundary uncast.
* factory): plain cross-service callbacks only — tree data rides the
* standard useSessions hook and viewing state is component-local. A type
* alias, not an interface: the alias carries an implicit index signature,
* so the factory's return crosses the registry's `Record<string, unknown>`
* boundary uncast.
*/
export type SidebarRootInjected = {
useTree: SnapshotSelectorHook<SidebarTreeState>
/** Current session selector (row highlight); undefined selects nothing. */
useCurrent: () => SessionId | undefined
actions: SidebarActions
tree: SidebarTreeActions
/** Open (switch to) a session. */
onOpen: (id: SessionId) => void
/**
* Create a session and open it; cwd targets a project group (the
* sidebar's three creation entries all land in the new session).
*/
onCreate: (cwd?: string) => void
/** Collapse the sidebar column (layout service action; owner share stays {collapsed,width}). */
onToggleSidebar: () => void
}
/**
* Full component props: owner share referenced from ui-layout's declaration
* plus the own injected share. Root scope has no standard injection
* (useSession is session-scope only), so no standard term appears.
* Full component props: the framework runtime share (owner {collapsed,width}
* + standard useSessions) plus the own injected share. No children are
* declared and no store is registered, so no PropsRenderSlots/PropsStore
* term appears.
*/
export type SidebarRootComponentProps = OwnerOf<'sidebar'> & SidebarRootInjected
export type SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected
+25 -45
View File
@@ -1,61 +1,41 @@
/**
* Sidebar plugin, browser half: SidebarRoot registered into the layout-owned
* sidebar slot; tree derivation materialized in a plugin-owned snapshot
* store (pure consumer — no ctx service). Contract: api-contracts v3
* section 6; props composition in contract/slots.ts.
* Export discipline: packages/client/AGENTS.md.
* sidebar slot. Pure consumer — the session list arrives through the
* standard useSessions prop, tree rows derive in the component, and the
* inject surface is plain cross-service callbacks closed over the plugin's
* own ctx (slot design sections 5 and 6); props composition in
* contract/slots.ts. Export discipline: packages/client/AGENTS.md.
*/
import type { RootBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootInjected } from './contract/slots.ts'
import { createSidebarTreeStore } from './store.ts'
import { SidebarRoot } from './SidebarRoot.tsx'
export type {
SidebarActions, SidebarRootComponentProps, SidebarRootInjected, SidebarTreeActions,
} from './contract/slots.ts'
export type { SidebarRootComponentProps, SidebarRootInjected } from './contract/slots.ts'
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
export const inject = ['slots', 'layout', 'sessions']
/**
* Client plugin body: build the tree store and register SidebarRoot into the
* sidebar slot with the inject surface bound off the root binding's ctx.
* Client plugin body: register SidebarRoot into the sidebar slot. The inject
* factory returns service callbacks only (no hooks, no store lines) — all
* data reads ride the framework's standard useSessions delivery.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const sessions = ctx.sessions
ctx.effect(() => {
const tree = createSidebarTreeStore(sessions)
// Called once per registration (root slots cache per entry); services are
// bound off the binding ctx per the contract's inject-surface wording.
const injectProps = (b: RootBinding<ClientContext>): SidebarRootInjected => {
const { sessions: boundSessions, layout } = b.ctx
return {
useTree: tree.store.useSelector,
useCurrent: () => layout.current.useSelector(s => s.sessionId),
actions: {
open: (id) => { layout.open(id) },
create: (cwd) => {
// Create-then-open: the sidebar's three creation entries all land
// in the new session (empty-state first-send stays with ui-conversation).
void boundSessions.create(cwd === undefined ? {} : { cwd })
.then((id: SessionId) => { layout.open(id) })
},
toggleSidebar: () => { layout.toggleSidebar() },
},
tree: {
toggleProject: (key) => { tree.toggleProject(key) },
toggleSession: (id) => { tree.toggleSession(id) },
setQuery: (query) => { tree.setQuery(query) },
},
}
}
const disposeRegistration = ctx.slots.register('sidebar', SidebarRoot, { inject: injectProps })
return () => {
disposeRegistration()
tree.dispose()
}
}, 'ui-sidebar: tree store + slot registration')
const injectProps = (): SidebarRootInjected => ({
// Selection lives with the runtime sessions service (current rides the
// list snapshot); layout keeps only panel geometry.
onOpen: (id) => { ctx.sessions.open(id) },
onCreate: (cwd) => {
// Create-then-open: the sidebar's three creation entries all land
// in the new session (empty-state first-send stays with ui-conversation).
void ctx.sessions.create(cwd === undefined ? {} : { cwd })
.then((id: SessionId) => { ctx.sessions.open(id) })
},
onToggleSidebar: () => { ctx.layout.toggleSidebar() },
})
ctx.effect(
() => ctx.slots.register({ name: 'sidebar', inject: injectProps }, SidebarRoot),
'ui-sidebar: slot registration',
)
}
@@ -1,94 +0,0 @@
/**
* Sidebar tree store: plugin-owned snapshot store materializing the derived
* row list. Subscribes to sessions.list and re-derives on list changes and
* on viewing-state actions (expansion, search, group-by) — components
* subscribe to `rows` and never derive in render. Contract: api-contracts
* v3 section 6.
*/
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { deriveRows, type SidebarRow } from './tree.ts'
/** Grouping strategy. Only by-workspace is designed (figma); the menu shows the rest disabled. */
export type GroupBy = 'workspace'
/** Sidebar tree state: materialized rows plus the viewing state that shaped them. */
export interface SidebarTreeState {
rows: SidebarRow[]
/** Expanded project group keys (cwd or the ungrouped key). */
expandedProjects: string[]
/** Expanded session ids (subtree unfold). */
expandedSessions: string[]
query: string
groupBy: GroupBy
}
/** Store handle: snapshot store plus mutation actions and the list unsubscribe. */
export interface SidebarTreeStore {
readonly store: SnapshotStore<SidebarTreeState>
toggleProject(key: string): void
toggleSession(id: SessionId): void
setQuery(query: string): void
setGroupBy(groupBy: GroupBy): void
dispose(): void
}
/**
* Create the sidebar tree store bound to a sessions service.
* @param sessions - root sessions service (only the list store is consumed).
* @returns store handle; call dispose on plugin teardown.
*/
export function createSidebarTreeStore(sessions: Pick<SessionsService, 'list'>): SidebarTreeStore {
const store = createSnapshotStore<SidebarTreeState>({
rows: [],
expandedProjects: [],
expandedSessions: [],
query: '',
groupBy: 'workspace',
})
const rederive = (draft: SidebarTreeState): void => {
draft.rows = deriveRows(sessions.list.getSnapshot(), {
expandedProjects: new Set(draft.expandedProjects),
expandedSessions: new Set(draft.expandedSessions),
query: draft.query,
})
}
store.update(rederive)
const unsubscribe = sessions.list.subscribe(() => { store.update(rederive) })
const toggle = (list: string[], key: string): void => {
const at = list.indexOf(key)
if (at >= 0) list.splice(at, 1)
else list.push(key)
}
return {
store,
toggleProject(key) {
store.update((draft) => {
toggle(draft.expandedProjects, key)
rederive(draft)
})
},
toggleSession(id) {
store.update((draft) => {
toggle(draft.expandedSessions, id)
rederive(draft)
})
},
setQuery(query) {
store.update((draft) => {
draft.query = query
rederive(draft)
})
},
setGroupBy(groupBy) {
store.update((draft) => {
draft.groupBy = groupBy
rederive(draft)
})
},
dispose: unsubscribe,
}
}
+11 -8
View File
@@ -2,8 +2,9 @@
* Pure sidebar tree derivation: session list snapshot -> flat render rows.
* Groups sessions by project directory (cwd), builds the per-group session
* tree from parentId links, sorts by recency, and applies search filtering
* with forced ancestor visibility. Components subscribe to the materialized
* rows and never derive in render. Contract: api-contracts v3 section 6.
* with forced ancestor visibility. Derived data is a pure function (slot
* design section 6): the component feeds the useSessions snapshot plus its
* local viewing state through useMemo — no materializing store.
*/
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
@@ -43,10 +44,10 @@ export interface SessionRow {
/** One flat sidebar list row. */
export type SidebarRow = ProjectRow | SessionRow
/** Viewing state consumed by the derivation. */
/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */
export interface TreeView {
expandedProjects: ReadonlySet<string>
expandedSessions: ReadonlySet<string>
expandedProjects: readonly string[]
expandedSessions: readonly string[]
query: string
}
@@ -217,17 +218,19 @@ function flattenSearch(g: Group, visible: ReadonlySet<SessionId>, rows: SidebarR
* without a title or label hit are dropped, and a label-only hit keeps the
* bare project row.
* @param list - sessions list snapshot.
* @param view - expansion sets and search query.
* @param view - local expansion arrays and search query.
* @returns rows in render order.
*/
export function deriveRows(list: SessionListState, view: TreeView): SidebarRow[] {
const q = view.query.trim().toLowerCase()
const expandedProjects = new Set(view.expandedProjects)
const expandedSessions = new Set(view.expandedSessions)
const rows: SidebarRow[] = []
for (const g of groupByCwd(list)) {
if (q === '') {
const expanded = view.expandedProjects.has(g.key)
const expanded = expandedProjects.has(g.key)
rows.push({ type: 'project', key: g.key, cwd: g.cwd, label: g.label, sessionCount: g.summaries.size, expanded })
if (expanded) flattenVisible(g, view.expandedSessions, rows)
if (expanded) flattenVisible(g, expandedSessions, rows)
} else {
const visible = searchVisible(g, q)
if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue
+4 -4
View File
@@ -15,10 +15,10 @@ export const name = 'client-ui-sidebar-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: a pure-consumer plugin deriving its tree store from
* sessions.list — it emits no cordis events and owns no cross-plugin mutable
* state; derivation and interaction behavior are asserted directly by this
* package's tree/store/component specs.
* No runtime invariant: a pure-consumer plugin deriving its rows in-component
* from the standard useSessions delivery — it emits no cordis events and owns
* no cross-plugin mutable state; derivation and interaction behavior are
* asserted directly by this package's tree/component specs.
*/
const install: InvariantInstaller = () => {}
+52 -98
View File
@@ -1,55 +1,53 @@
// @vitest-environment jsdom
/**
* apply wiring on a real cordis Context + SlotsService: tree store built and
* subscribed, SidebarRoot registered into the layout-owned sidebar slot with
* the inject surface bound off the root binding ctx, effect teardown
* unregisters and drops the list subscription. Behavior-level assertions
* only — the inject factory's cast shape is due to change with the slot
* type-chain redesign.
* apply wiring on a real cordis Context + SlotsService (terminal register
* form): SidebarRoot registered into the layout-declared sidebar slot, the
* thin inject surface (three plain service callbacks closed over the plugin
* ctx — no hooks, no store lines), load-order fail-loud, and fiber-teardown
* unregistration. Component behavior is covered props-direct in
* sidebar-root.spec.tsx; no renderer machinery here.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act } from 'react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { scopedSlots, RootBindingProvider } from '@deepseek-ai/dsh-client-web-react'
import { describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
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'
import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client'
// Type-only: ui-layout's SlotMap merge so the sidebar slot key typechecks.
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
const sid = (s: string) => s as SessionId
afterEach(cleanup)
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const list = createSnapshotStore<SessionListState>({
ids: [sid('a')],
byId: { [sid('a')]: { id: sid('a'), title: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } },
current: undefined,
})
const sessions = { list, create: vi.fn(async () => sid('minted')) }
const layout = {
current: createSnapshotStore<{ sessionId?: SessionId }>({}),
open: vi.fn(),
toggleSidebar: vi.fn(),
}
const sessions = { list, create: vi.fn(async () => sid('minted')), open: vi.fn() }
const layout = { toggleSidebar: vi.fn() }
ctx.provide('sessions', sessions)
ctx.provide('layout', layout)
const slots = ctx.get('slots') as SlotsService
slots.define('sidebar', { kind: 'single', scope: 'root' })
// Stand-in for ui-layout's root entry: the sidebar slot only exists while
// a live entry declares it in children (declaration account: design §2.2).
slots.register(
{ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never,
() => null,
)
return { ctx, slots, sessions, layout }
}
function mountSlot(ctx: Context, slots: SlotsService) {
const surface = scopedSlots(slots.core, 'sidebar')
return render(
<RootBindingProvider value={{ ctx }}>
{surface.renderSlot('sidebar', {})}
</RootBindingProvider>,
)
/** The sidebar entry's injected share, read off the stored entry. */
function injectedOf(slots: SlotsService): SidebarRootInjected {
const entries = slots.entries('sidebar')
expect(entries).toHaveLength(1)
// The typed StoredEntry.inject is declaration-derived ((...args: never[])
// shape); the sidebar factory is parameterless, so the call is safe here.
const inject = entries[0]!.inject as (() => SidebarRootInjected) | undefined
return inject!()
}
describe('apply', () => {
@@ -58,101 +56,57 @@ describe('apply', () => {
})
it('fails loud when mounted without the inject declaration', async () => {
// ctx.sessions rides the cordis property proxy: reading it from a plugin
// ctx.slots rides the cordis property proxy: reading it from a plugin
// that never declared the dependency throws instead of yielding undefined.
// Await the fiber thenable itself, not a second .await() chain: the test
// invariant host wraps plugin() with an eager readiness promise, and only
// the thenable settles it (a parallel .await() leaves it unhandled).
const ctx = new Context()
await ctx.plugin(SlotsService).await()
await expect(ctx.plugin({ apply })).rejects.toThrow(/without inject/)
})
it('registers SidebarRoot which renders from the live list', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
mountSlot(ctx, slots)
expect(screen.getByText('proj')).toBeTruthy()
expect(screen.getByText('1 session')).toBeTruthy()
it('fails loud when no live entry has declared the sidebar slot', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
ctx.provide('sessions', {})
ctx.provide('layout', {})
await expect(ctx.plugin({ inject: [...inject], apply })).rejects.toThrow(/slot "sidebar" is not declared/)
})
it('binds actions to layout/sessions off the root binding', async () => {
it('registers SidebarRoot with the thin three-callback inject surface', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const injected = injectedOf(slots)
// The whole business face: three plain callbacks, no hooks, no store lines.
expect(Object.keys(injected).sort()).toEqual(['onCreate', 'onOpen', 'onToggleSidebar'])
})
it('routes the callbacks to the layout/sessions services', async () => {
const { ctx, slots, sessions, layout } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
mountSlot(ctx, slots)
const injected = injectedOf(slots)
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
injected.onToggleSidebar()
expect(layout.toggleSidebar).toHaveBeenCalledOnce()
act(() => { fireEvent.click(screen.getByText('proj')) })
act(() => { fireEvent.click(screen.getByText('alpha')) })
expect(layout.open).toHaveBeenCalledWith('a')
injected.onOpen(sid('a'))
expect(sessions.open).toHaveBeenCalledWith('a')
act(() => { fireEvent.click(screen.getByText('New Session')) })
injected.onCreate()
expect(sessions.create).toHaveBeenCalledWith({})
// create-then-open lands after the create promise resolves.
await act(async () => { await Promise.resolve() })
expect(layout.open).toHaveBeenCalledWith('minted')
await Promise.resolve()
await Promise.resolve()
expect(sessions.open).toHaveBeenCalledWith('minted')
act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) })
injected.onCreate('/proj')
expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' })
})
it('throws from the inject factory when binding ctx lacks the services', async () => {
it('teardown unregisters the slot entry', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const bare = new Context()
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const surface = scopedSlots(slots.core, 'sidebar')
render(
<RootBindingProvider value={{ ctx: bare }}>
{surface.renderSlot('sidebar', {})}
</RootBindingProvider>,
)
// The slot error boundary absorbs the throw and logs it.
expect(document.querySelector('[data-slot-error="sidebar"]')).toBeTruthy()
} finally {
spy.mockRestore()
}
})
it('search input drives the plugin-owned tree store', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
mountSlot(ctx, slots)
act(() => {
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'zzz' } })
})
expect(screen.getByText('No matches')).toBeTruthy()
})
it('expansion toggles route through the injected tree actions', async () => {
const { ctx, slots, sessions } = await bench()
sessions.list.update((draft) => {
draft.ids.push(sid('kid'))
draft.byId[sid('kid')] = {
id: sid('kid'), title: 'child', cwd: '/proj', parentId: sid('a'), running: false, updatedAt: 2,
}
})
await ctx.plugin({ inject: [...inject], apply }).await()
mountSlot(ctx, slots)
act(() => { fireEvent.click(screen.getByText('proj')) })
expect(screen.getByText('alpha')).toBeTruthy()
act(() => { fireEvent.click(screen.getByLabelText('Expand')) })
expect(screen.getByText('child')).toBeTruthy()
})
it('teardown unregisters the slot and drops the list subscription', async () => {
const { ctx, slots, sessions } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('sidebar')).toHaveLength(1)
await fiber.dispose()
expect(slots.entries('sidebar')).toHaveLength(0)
// A post-teardown list change must not reach a disposed store.
expect(() => {
sessions.list.update((draft) => { draft.ids = [] })
}).not.toThrow()
})
})
@@ -1,18 +1,20 @@
// @vitest-environment jsdom
/**
* SidebarRoot interaction spec on the real framework stack: real tree store
* (web-react SnapshotStore) feeding the component through the same selector
* hook the inject surface hands out. Covers expand/collapse, subtree unfold,
* search filtering, row activation, and the creation entries.
* SidebarRoot interaction spec, props-direct (slot-parity test doctrine:
* components are fed composed props, no assembly machinery). The standard
* useSessions hook is stubbed with a real web-react SnapshotStore selector;
* expansion/search live inside the component, so all viewing behavior is
* driven through the DOM. Covers expand/collapse, subtree unfold, search
* filtering, row activation, and the creation entries.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act } from 'react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-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 type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import { createSidebarTreeStore, type SidebarTreeStore } from '../src/client/store.ts'
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
import type { SidebarActions } from '@deepseek-ai/dsh-client-ui-sidebar/client'
const sid = (s: string) => s as SessionId
@@ -41,29 +43,29 @@ function summary(init: SummaryInit): SessionSummary {
function listStateOf(...summaries: SessionSummary[]): SessionListState {
const byId: Record<SessionId, SessionSummary> = {}
for (const s of summaries) byId[s.id] = s
return { ids: summaries.map((s) => s.id), byId }
return { ids: summaries.map((s) => s.id), byId, current: undefined }
}
afterEach(cleanup)
function mount(...summaries: SessionSummary[]) {
const list = createSnapshotStore<SessionListState>(listStateOf(...summaries))
const tree: SidebarTreeStore = createSidebarTreeStore({ list })
const current = createSnapshotStore<{ id: SessionId | undefined }>({ id: undefined })
const actions: SidebarActions = {
open: vi.fn((id: SessionId) => { current.update((d) => { d.id = id }) }),
create: vi.fn(),
toggleSidebar: vi.fn(),
}
// Real engine store as the useSessions stub: same uSES selector shape the
// framework delivers, so list updates re-render exactly like production.
const sessions = createSnapshotStore<SessionListState>(listStateOf(...summaries))
const onOpen = vi.fn((id: SessionId) => { sessions.update((d) => { d.current = id }) })
const onCreate = vi.fn()
const onToggleSidebar = vi.fn()
const utils = render(
<SidebarRoot
useTree={tree.store.useSelector}
useCurrent={() => current.useSelector((s) => s.id)}
actions={actions}
tree={tree}
collapsed={false}
width={300}
useSessions={sessions.useSelector}
onOpen={onOpen}
onCreate={onCreate}
onToggleSidebar={onToggleSidebar}
/>,
)
return { list, tree, current, actions, ...utils }
return { sessions, onOpen, onCreate, onToggleSidebar, ...utils }
}
const projectData = () => [
@@ -72,6 +74,9 @@ const projectData = () => [
summary({ id: 'lone', title: 'elsewhere', cwd: '/other', updatedAt: 3 }),
]
/** Flush the store's microtask-batched notification into React. */
const flush = async () => { await act(async () => { await Promise.resolve() }) }
describe('SidebarRoot', () => {
it('renders chrome and collapsed project rows', () => {
mount(...projectData())
@@ -94,11 +99,13 @@ describe('SidebarRoot', () => {
expect(screen.queryByText('forked child')).toBeNull()
})
it('opens a session on row click and marks it selected', () => {
const { actions } = mount(...projectData())
it('opens a session on row click and marks it selected', async () => {
const { onOpen } = mount(...projectData())
act(() => { fireEvent.click(screen.getByText('proj')) })
act(() => { fireEvent.click(screen.getByText('root work')) })
expect(actions.open).toHaveBeenCalledWith('root')
expect(onOpen).toHaveBeenCalledWith('root')
// The mock routed the open into sessions.current — highlight follows.
await flush()
expect(screen.getByText('root work').closest('[role="treeitem"]')!.getAttribute('aria-selected')).toBe('true')
})
@@ -128,20 +135,20 @@ describe('SidebarRoot', () => {
})
it('routes the three creation entries with the right cwd', () => {
const { actions } = mount(...projectData())
const { onCreate } = mount(...projectData())
act(() => { fireEvent.click(screen.getByText('New Session')) })
expect(actions.create).toHaveBeenLastCalledWith()
expect(onCreate).toHaveBeenLastCalledWith()
act(() => { fireEvent.click(screen.getByLabelText('New workspace')) })
expect(actions.create).toHaveBeenLastCalledWith()
expect(onCreate).toHaveBeenLastCalledWith()
// Per-project "+" is hover-revealed by CSS; still clickable in jsdom.
act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) })
expect(actions.create).toHaveBeenLastCalledWith('/proj')
expect(onCreate).toHaveBeenLastCalledWith('/proj')
})
it('collapse button and group-by menu behave', () => {
const { actions } = mount(...projectData())
const { onToggleSidebar } = mount(...projectData())
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
expect(actions.toggleSidebar).toHaveBeenCalledOnce()
expect(onToggleSidebar).toHaveBeenCalledOnce()
expect(screen.queryByText('Update')).toBeNull()
act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
expect(screen.getByText('Update')).toBeTruthy()
@@ -156,28 +163,27 @@ describe('SidebarRoot', () => {
})
it('re-renders when the sessions list gains a session', async () => {
const { list } = mount(...projectData())
const { sessions } = mount(...projectData())
act(() => {
list.update((draft) => {
sessions.update((draft) => {
draft.ids.push(sid('fresh'))
draft.byId[sid('fresh')] = summary({ id: 'fresh', title: 'brand new', cwd: '/fresh', updatedAt: 99 })
})
})
// Store notifications are microtask-batched.
await act(async () => { await Promise.resolve() })
await flush()
expect(screen.getByText('fresh')).toBeTruthy()
})
it('row "More" anchors swallow the click without opening or toggling', () => {
const { actions, tree } = mount(...projectData())
const { onOpen } = mount(...projectData())
act(() => { fireEvent.click(screen.getByText('proj')) })
const before = tree.store.getSnapshot().expandedProjects.length
// Project-row anchor: must not collapse the project.
// Project-row anchor: must not collapse the project (rows stay visible).
act(() => { fireEvent.click(screen.getAllByLabelText('More')[0]!) })
expect(tree.store.getSnapshot().expandedProjects).toHaveLength(before)
expect(screen.getByText('root work')).toBeTruthy()
// Session-row anchor: must not open the session.
act(() => { fireEvent.click(screen.getAllByLabelText('More')[1]!) })
expect(actions.open).not.toHaveBeenCalled()
expect(onOpen).not.toHaveBeenCalled()
})
it('shows the running state dot only for running sessions', () => {
@@ -1,111 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import { createSidebarTreeStore } from '../src/client/store.ts'
const sid = (s: string) => s as SessionId
/** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
interface SummaryInit {
id: string
title?: string
cwd?: string
parentId?: string
running?: boolean
updatedAt?: number
}
function summary(init: SummaryInit): SessionSummary {
const s: SessionSummary = {
id: sid(init.id),
title: init.title ?? init.id,
running: init.running ?? false,
updatedAt: init.updatedAt ?? 0,
}
if (init.cwd !== undefined) s.cwd = init.cwd
if (init.parentId !== undefined) s.parentId = sid(init.parentId)
return s
}
function listStateOf(...summaries: SessionSummary[]): SessionListState {
const byId: Record<SessionId, SessionSummary> = {}
for (const s of summaries) byId[s.id] = s
return { ids: summaries.map(s => s.id), byId }
}
function setup(...summaries: SessionSummary[]) {
const list = createSnapshotStore<SessionListState>(listStateOf(...summaries))
const tree = createSidebarTreeStore({ list })
return { list, tree }
}
const flushMicrotasks = () => new Promise<void>((resolve) => { queueMicrotask(resolve) })
describe('createSidebarTreeStore', () => {
it('materializes rows from the initial list snapshot', () => {
const { tree } = setup(summary({ id: 'a', cwd: '/p' }))
expect(tree.store.getSnapshot().rows).toEqual([
expect.objectContaining({ type: 'project', key: '/p', sessionCount: 1 }),
])
})
it('re-derives when the sessions list changes', async () => {
const { list, tree } = setup(summary({ id: 'a', cwd: '/p' }))
list.update((draft) => {
draft.ids.push(sid('b'))
draft.byId[sid('b')] = summary({ id: 'b', cwd: '/q', updatedAt: 99 })
})
// Snapshot-store notifications are microtask-batched.
await flushMicrotasks()
expect(tree.store.getSnapshot().rows.map(r => r.type === 'project' && r.key)).toEqual(['/q', '/p'])
})
it('toggleProject expands and collapses synchronously', () => {
const { tree } = setup(summary({ id: 'a', cwd: '/p' }))
tree.toggleProject('/p')
expect(tree.store.getSnapshot().rows).toHaveLength(2)
tree.toggleProject('/p')
expect(tree.store.getSnapshot().rows).toHaveLength(1)
})
it('toggleSession unfolds a subtree', () => {
const { tree } = setup(
summary({ id: 'root', cwd: '/p', updatedAt: 2 }),
summary({ id: 'kid', cwd: '/p', parentId: sid('root'), updatedAt: 1 }),
)
tree.toggleProject('/p')
expect(tree.store.getSnapshot().rows).toHaveLength(2)
tree.toggleSession(sid('root'))
expect(tree.store.getSnapshot().rows).toHaveLength(3)
})
it('setQuery switches into search mode and back', () => {
const { tree } = setup(
summary({ id: 'a', title: 'needle', cwd: '/p' }),
summary({ id: 'b', title: 'other', cwd: '/q' }),
)
tree.setQuery('needle')
const rows = tree.store.getSnapshot().rows
expect(rows.map(r => r.type)).toEqual(['project', 'session'])
tree.setQuery('')
expect(tree.store.getSnapshot().rows.every(r => r.type === 'project')).toBe(true)
})
it('setGroupBy records the strategy and re-derives', () => {
const { tree } = setup(summary({ id: 'a', cwd: '/p' }))
tree.setGroupBy('workspace')
expect(tree.store.getSnapshot().groupBy).toBe('workspace')
expect(tree.store.getSnapshot().rows).toHaveLength(1)
})
it('dispose stops re-derivation on list changes', async () => {
const { list, tree } = setup(summary({ id: 'a', cwd: '/p' }))
tree.dispose()
list.update((draft) => {
draft.ids.push(sid('b'))
draft.byId[sid('b')] = summary({ id: 'b', cwd: '/q' })
})
await flushMicrotasks()
expect(tree.store.getSnapshot().rows).toHaveLength(1)
})
})
+12 -12
View File
@@ -32,12 +32,12 @@ function summary(init: SummaryInit): SessionSummary {
function listOf(...summaries: SessionSummary[]): SessionListState {
const byId: Record<SessionId, SessionSummary> = {}
for (const s of summaries) byId[s.id] = s
return { ids: summaries.map(s => s.id), byId }
return { ids: summaries.map(s => s.id), byId, current: undefined }
}
const view = (partial: Partial<TreeView> = {}): TreeView => ({
expandedProjects: partial.expandedProjects ?? new Set(),
expandedSessions: partial.expandedSessions ?? new Set(),
expandedProjects: partial.expandedProjects ?? [],
expandedSessions: partial.expandedSessions ?? [],
query: partial.query ?? '',
})
@@ -94,7 +94,7 @@ describe('deriveRows grouping', () => {
summary({ id: 'b', cwd: '/p', updatedAt: 2 }),
)
expect(deriveRows(list, view()).filter(r => r.type === 'session')).toHaveLength(0)
const rows = deriveRows(list, view({ expandedProjects: new Set(['/p']) }))
const rows = deriveRows(list, view({ expandedProjects: ['/p'] }))
expect(rows.slice(1)).toEqual([
expect.objectContaining({ type: 'session', id: 'b', depth: 0 }),
expect.objectContaining({ type: 'session', id: 'a', depth: 0 }),
@@ -112,8 +112,8 @@ describe('deriveRows session tree', () => {
it('nests children under expanded parents with increasing depth', () => {
const rows = deriveRows(treeList, view({
expandedProjects: new Set(['/p']),
expandedSessions: new Set(['root', 'kid']),
expandedProjects: ['/p'],
expandedSessions: ['root', 'kid'],
}))
expect(rows.slice(1)).toEqual([
expect.objectContaining({ id: 'other', depth: 0, hasChildren: false }),
@@ -124,7 +124,7 @@ describe('deriveRows session tree', () => {
})
it('collapses subtrees at unexpanded sessions', () => {
const rows = deriveRows(treeList, view({ expandedProjects: new Set(['/p']) }))
const rows = deriveRows(treeList, view({ expandedProjects: ['/p'] }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toEqual(['other', 'root'])
})
@@ -133,7 +133,7 @@ describe('deriveRows session tree', () => {
const rows = deriveRows(listOf(
summary({ id: 'p1', cwd: '/a', updatedAt: 2 }),
summary({ id: 'stray', cwd: '/b', parentId: sid('p1'), updatedAt: 1 }),
), view({ expandedProjects: new Set(['/a', '/b']) }))
), view({ expandedProjects: ['/a', '/b'] }))
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: '/a' }),
expect.objectContaining({ id: 'p1', depth: 0 }),
@@ -147,7 +147,7 @@ describe('deriveRows session tree', () => {
summary({ id: 'x', cwd: '/p', parentId: sid('y'), updatedAt: 2 }),
summary({ id: 'y', cwd: '/p', parentId: sid('x'), updatedAt: 1 }),
summary({ id: 'self', cwd: '/p', parentId: sid('self'), updatedAt: 3 }),
), view({ expandedProjects: new Set(['/p']), expandedSessions: new Set(['x', 'y', 'self']) }))
), view({ expandedProjects: ['/p'], expandedSessions: ['x', 'y', 'self'] }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toContain('self')
expect(ids).toContain('x')
@@ -160,7 +160,7 @@ describe('deriveRows session tree', () => {
summary({ id: 'b', cwd: '/p', updatedAt: 7 }),
summary({ id: 'a', cwd: '/p', updatedAt: 7 }),
summary({ id: 'c', cwd: '/p', updatedAt: 7 }),
), view({ expandedProjects: new Set(['/p']) }))
), view({ expandedProjects: ['/p'] }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toEqual(['a', 'b', 'c'])
})
@@ -170,7 +170,7 @@ describe('deriveRows session tree', () => {
summary({ id: 'p', cwd: '/p', updatedAt: 9 }),
summary({ id: 'old', cwd: '/p', parentId: sid('p'), updatedAt: 1 }),
summary({ id: 'new', cwd: '/p', parentId: sid('p'), updatedAt: 5 }),
), view({ expandedProjects: new Set(['/p']), expandedSessions: new Set(['p']) }))
), view({ expandedProjects: ['/p'], expandedSessions: ['p'] }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toEqual(['p', 'new', 'old'])
})
@@ -178,7 +178,7 @@ describe('deriveRows session tree', () => {
it('carries the running flag onto rows', () => {
const rows = deriveRows(
listOf(summary({ id: 'a', cwd: '/p', running: true })),
view({ expandedProjects: new Set(['/p']) }))
view({ expandedProjects: ['/p'] }))
expect(rows[1]).toEqual(expect.objectContaining({ id: 'a', running: true }))
})
})
+17 -4
View File
@@ -1,8 +1,21 @@
# @deepseek-ai/dsh-client-ui-slots
Slot registry pure core: SlotMap declaration merging, SlotCore (single/list/keyed), ScopedSlots types. Contract: api-contracts v3 §1 + the slot type-chain design (composed-props registration).
Slot registry pure core, slot terminal design: SlotMap declaration merging, the single `register` composition API on SlotCore, the four-share component-props type family, the store-seat type family, and the renderer install-seam contract. React types only at runtime — the package is React-free and cordis-free.
A SlotMap entry declares `{ kind; scope; owner; children? }`. `owner` is the render-side props share the slot-owning package declares; registrants reference it through `OwnerOf<K>` and never re-state it. The registrant's injected share `I` stays a local type at the registration site, inferred from the inject factory (`InjectFactory<E, I, Ctx>`; context-narrowing wrappers pin `Ctx`). `SlotCore.register` constrains the component against `ComposedProps<K, I>` — owner share & bottom-typed standard share (`StandardOf`) & `children`-gated slots face (`SlotsFaceOf`) & `I` — through the bare-call-signature `SlotComponent` position. `children` optionally whitelists delegable sub-slot keys (`ChildrenOf`; constraint-side validation only — delivery stays with the renderer); `narrowSlots` narrows a `ScopedSlots` surface to a subset whitelist.
One `register({ name, children?, store?, inject?, ...kind }, Component)` call contributes a component into a declared slot and, in the same breath, declares child slots (declaration = render authorization = runtime spec, one table), a store seat, and the registrant's business face. The component is checked at the call site against `ComposedProps` — the intersection of four shares, each derived from its single source of truth:
| share | type | source |
|---|---|---|
| runtime | `PropsRuntime<K>` | SlotMap entry: `owner` (parent's renderSlot call site) + session standard kit + global seat |
| child render | `PropsRenderSlots<S>` | the register call's `children` key set (statically narrowed `renderSlot`) |
| store | `PropsStore<H>` | the declared handle: `useStore` selector hook + draft-stripped `actions` |
| business | `I` | inferred from the `inject` factory's return |
The standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`) are declared empty here and merged by the runtime package (same declare-merge pattern as SlotMap keys). Inject factory parameters derive from the declaration (`InjectParams`): session slots get `sessionId`, a declared store appends baked `actions`, nothing else — data access lives in the apply closure's ctx.
The store family (`defineStore` spec in / `StoreHandle<T, A>` out) types the store seat: `init` infers the state schema, `actions` is the complete draft-transform write set, `BakedActions` strips the draft parameter into the callbacks components and inject factories receive. The `defineStore` value implementation lives in web-react (the engine's home) and satisfies the `DefineStore` contract exported here.
`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.
## Model Experience
@@ -14,5 +27,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **`StandardOf` is a constraint-position bottom type (`useSession: never`), not the arriving hook type** — this zero-dependency layer cannot see the conversation snapshot; components declare the narrowed hook they consume, and what actually arrives is web-react's renderer responsibility.
- **The legacy `props` entry member (with `OwnerProps`'s Partial owner share) remains for migration** — entries not yet declaring `owner` keep the P-I full-props constraint; both forms disappear with the last legacy declaration.
- **`isLive` scans all records linearly** — fine at UI-plugin registration counts (tens); revisit with an entry→record backref if ledgers ever grow hot.
- **The `__renders` phantom anchor is visible on `PropsRenderSlots`** — the same accepted noise as the type-chain design's `__accepts`: generic method signatures compare loosely across key unions, so the contravariant marker is what enforces "component key set ⊆ children declaration".
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-slots",
"description": "Slot registry pure core: SlotMap declaration-merge surface, SlotCore (single/list/keyed), ScopedSlots types",
"description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam",
"version": "0.0.1",
"private": true,
"type": "module",
+374 -251
View File
@@ -1,18 +1,23 @@
/**
* Slot registry pure core. Owners declare slot contracts by merging into
* {@link SlotMap}; `define` records the runtime spec, `register` contributes a
* component. Zero runtime dependencies (React types only).
* Slot registry pure core (slot terminal design). Owners declare slot
* contracts by merging into {@link SlotMap}; one `register` call contributes a
* component AND (optionally) declares child slots, a store seat, and the
* registrant's business face. Zero runtime dependencies (React types only).
*
* SlotMap and its companion types live directly in this entry module: consumer
* `declare module` augmentation merges with declarations lexically in the
* augmented module, not with re-exports.
* SlotMap and the standard-kit interfaces live directly in this entry module:
* consumer `declare module` augmentation merges with declarations lexically in
* the augmented module, not with re-exports.
*/
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
* in THIS compilation unit (so the intersection reads as `never`), but every
* 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 { FC, ReactNode } from 'react'
import type { ReactNode } from 'react'
import type { BoundActions, HandleOf, PropsStore, StoreDecl } from './store.ts'
export * from './store.ts'
export * from './renderer.ts'
/** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */
export interface SlotMap {}
@@ -24,147 +29,207 @@ export type SlotKind = 'single' | 'list' | 'keyed'
export type SlotScope = 'root' | 'session'
/**
* One SlotMap entry: kind/scope axes, the owner-supplied props share, and an
* optional sub-slot whitelist. Ownership rule (a share's type lives with
* whoever wires it): `owner` is the render-side share declared by the
* slot-owning package and REFERENCED by registrants; the registrant's own
* injected share never enters this table — full component props are composed
* at the component as `OwnerOf<K> & <injector-narrowed standard> & OwnInjected`.
* `props` is the legacy full-props slot kept while consumers migrate to
* composed declarations (new entries declare `owner` and omit it).
* One SlotMap entry: kind/scope axes plus the optional owner-supplied props
* share (`owner` is what the parent passes at its renderSlot call site; the
* framework standard kit and the registrant's injected share never enter this
* table — full component props compose at the component as the four-share
* intersection, see {@link ComposedProps}).
*/
export interface SlotEntryDef {
kind: SlotKind
scope: SlotScope
props?: object
owner?: object
children?: keyof SlotMap & string
}
/**
* Owner-supplied props share for a slot key: the render-side contract half.
* Falls back to the legacy Partial form when the entry declares no `owner`.
* Runtime dispatch spec for one slot, recorded from a register call's
* `children` value. The literal is compile-time checked against the SlotMap
* entry (`SlotSpec<SlotMap[P]>` in {@link ChildrenDecl}), so type and value
* are declared at one point and validate each other.
*/
export type OwnerOf<K extends keyof SlotMap & string> =
SlotMap[K] extends { owner: infer O extends object } ? O : OwnerProps<SlotMap[K]>
/**
* CONSTRAINT-position standard share for register(): the framework supplies
* session slots' bound selector hook, so it is bottom-typed here — any
* registrant narrowing (e.g. a runtime-typed conversation hook) is accepted,
* and the type responsibility for what actually arrives lives with the
* injecting side (web-react's renderer). Do NOT compose component props from
* this type; declare the narrowed hook the component actually consumes.
*/
export type StandardOf<K extends keyof SlotMap & string> =
SlotMap[K]['scope'] extends 'session' ? { useSession: never } : object
/**
* Delegable sub-slot whitelist declared by an entry: the `children` key union,
* or `never` when the entry declares none (no delegation authorized).
*/
export type ChildrenOf<K extends keyof SlotMap & string> =
SlotMap[K] extends { children: infer C extends keyof SlotMap & string } ? C : never
/**
* B-b optional validation layer over the hand-written whitelist (B-a): the
* composed register constraint carries a `slots` face whose key union is the
* entry's `children` declaration. Components wanting a SUBSET whitelist
* accept it through ScopedSlots covariance; an out-of-union whitelist makes
* the component's `slots` parameter unsatisfiable and the register call site
* reports it. Entries without `children` add no slots constraint, so any
* hand-written whitelist registers freely (the layer is opt-in per entry).
* This is constraint-side only: whether slots are actually delivered stays
* with the renderer/owner (B-a trust).
*/
export type SlotsFaceOf<K extends keyof SlotMap & string> =
[ChildrenOf<K>] extends [never]
? object
: { slots: ScopedSlots<ChildrenOf<K>> }
/**
* The registration-boundary composed props constraint: owner share + standard
* share + registrant share, gated by the entry's `children` authorization
* (see {@link ChildrenChecked}). Entries without an `owner` declaration
* (legacy full-props form) keep the plain props constraint until they migrate.
*/
export type ComposedProps<K extends keyof SlotMap & string, I extends object> =
SlotMap[K] extends { owner: infer O extends object }
? O & StandardOf<K> & SlotsFaceOf<K> & I
: PropsShape<SlotMap[K]>
/**
* Registration-position component shape: the bare call signature, so the
* ComposedProps constraint checks through clean parameter contravariance
* (FC's propTypes/defaultProps statics add covariant noise that rejects
* legitimate narrowings of the bottom-typed standard share).
*/
export type SlotComponent<P> = (props: P) => ReactNode
/** The stored component's props shape: the legacy full-props slot, or wide for composed entries. */
export type PropsShape<E extends SlotEntryDef> =
E extends { props: infer P extends object } ? P : object
/**
* Session-scoped assembly handle passed to inject factories (apply world
* only, never into props). `Ctx` defaults to unknown at this zero-dependency
* layer; runtime re-exports the ClientContext-narrowed alias.
*/
export interface SessionBinding<Ctx = unknown> {
readonly sessionId: string
readonly session: SessionAccess
readonly ctx: Ctx
}
/** Root-scoped assembly handle passed to inject factories. */
export interface RootBinding<Ctx = unknown> { readonly ctx: Ctx }
/** Session subscription surface; web-react narrows `useSelector` to the typed hook. */
export interface SessionAccess { readonly useSelector: unknown }
/**
* Factory producing the registrant's private injected props, called once per
* (entry x session) for session slots or per entry for root slots. `I` is the
* registrant's own injected share, inferred at the registration site. `Ctx`
* parameterizes the binding's context (default unknown keeps this layer
* dependency-free); runtime's narrowed binding aliases flow through here so
* factories written against a narrowed ctx type-check without a cast.
*/
export type InjectFactory<E extends SlotEntryDef, I extends object = Record<string, unknown>, Ctx = unknown> =
(b: E['scope'] extends 'session' ? SessionBinding<Ctx> : RootBinding<Ctx>) => I
/** Runtime spec recorded at define time; must match the SlotMap declaration. */
export interface SlotSpec<E extends SlotEntryDef> { kind: E['kind']; scope: E['scope'] }
/**
* Registration options, shaped by the slot kind and the registrant's injected
* share `I`. `Ctx` flows through to the inject factory's binding parameter
* (narrowing wrappers fix it to their client context type).
* Child-slot declaration table for register(): keys are the declared (and
* thereby render-authorized) slot names, values are their runtime dispatch
* specs. Declaring is claiming: the registering entry becomes the only entry
* allowed to render these keys.
*/
export type SlotOptions<E extends SlotEntryDef, I extends object = Record<string, unknown>, Ctx = unknown> =
E['kind'] extends 'keyed' ? { key: string; inject?: InjectFactory<E, I, Ctx> }
: E['kind'] extends 'list' ? { id: string; order?: number; label?: string; inject?: InjectFactory<E, I, Ctx> }
: { inject?: InjectFactory<E, I, Ctx> }
export type ChildrenDecl = { [P in keyof SlotMap & string]?: SlotSpec<SlotMap[P]> }
/** register() trailing args: options are statically mandatory for keyed/list kinds (key/id live there). */
export type RegisterArgs<E extends SlotEntryDef, I extends object = Record<string, unknown>, Ctx = unknown> =
E['kind'] extends 'keyed' | 'list' ? [options: SlotOptions<E, I, Ctx>] : [options?: SlotOptions<E, I, Ctx>]
/** Owner-supplied props share for a slot key ({} for entries declaring no `owner`). */
export type OwnerOf<K extends keyof SlotMap & string> =
SlotMap[K] extends { owner: infer O extends object } ? O : object
/** One registered contribution: the component plus its registration options. */
export interface SlotEntry<E extends SlotEntryDef, I extends object = Record<string, unknown>> {
component: FC<PropsShape<E>>
options: SlotOptions<E, I>
/** Scope axis of a slot key's SlotMap entry. */
export type ScopeOf<K extends keyof SlotMap & string> = SlotMap[K]['scope']
/**
* Framework standard kit delivered to every session-scope slot component.
* Declared EMPTY here (zero-dependency layer): the runtime package merges the
* real members (`useSession` bound to the conversation snapshot and the
* framework-supplied `sessionId`) exactly as consumers merge SlotMap keys.
*/
export interface SessionStandardProps {}
/**
* Framework standard kit delivered to EVERY slot component (the global seat).
* Declared empty here; the runtime package merges `useSessions` (the session
* list selector hook — the sidebar tree's single derivation source).
*/
export interface GlobalStandardProps {}
/**
* The session id type as the runtime's SessionStandardProps merge declares it
* (branded); falls back to `string` in programs without the merge (this
* package's own tests).
*/
export type SessionIdOf = SessionStandardProps extends { sessionId: infer S } ? S : string
/**
* Runtime props share for a slot key: owner share (parent's renderSlot call
* site) + session standard kit (session scope only) + the global seat.
*/
export type PropsRuntime<K extends keyof SlotMap & string> =
OwnerOf<K> &
(ScopeOf<K> extends 'session' ? SessionStandardProps : object) &
GlobalStandardProps
/** renderSlot dispatch options: keyed dispatch key, list filtering, empty fallback. */
export interface RenderOpts { entryKey?: string; only?: string; fallback?: ReactNode }
/**
* Child-slot render share: `renderSlot` statically narrowed to the entry's
* declared children keys. Delegation is plain props passing (hand
* `props.renderSlot` down); the authorizing identity stays the registering
* entry. `__renders` is a phantom variance anchor (never materialized):
* generic method signatures compare loosely across differing key unions, so
* this contravariant marker is what actually enforces "component key set ⊆
* children declaration" at the register call site.
*/
export type PropsRenderSlots<S extends keyof SlotMap & string> = {
/**
* Render a declared child slot.
* @param key - declared child key.
* @param owner - owner props share for that key (decided at the render site).
* @param opts - kind dispatch options.
* @returns rendered node(s).
*/
renderSlot: <K extends S>(key: K, owner: OwnerOf<K>, opts?: RenderOpts) => ReactNode
readonly __renders?: ((key: S) => void) | undefined
}
/** Type-erased stored entry; public typing is restored at the entries() boundary. */
interface StoredEntry {
/**
* Registration-position component shape: the bare call signature, so composed
* constraints check through clean parameter contravariance (FC statics add
* covariant noise rejecting legitimate narrowings).
*/
export type SlotComponent<P> = (props: P) => ReactNode
/**
* The four-share component props intersection: runtime share (SlotMap) +
* child-render share (children declaration) + store share (declared handle) +
* the registrant's injected business face. Each share derives from its single
* source of truth; components reference this composition, never re-type it.
*/
export type ComposedProps<
K extends keyof SlotMap & string,
S extends keyof SlotMap & string,
H,
I extends object,
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & I
/**
* Inject factory parameter list, derived from the registration's declaration:
* session slots receive the framework-resolved `sessionId`; a declared store
* appends the baked `actions` (the same callbacks the component receives);
* root slots without a store take no parameters. Business data access happens
* through the apply closure's ctx — no binding object parameter exists.
*/
export type InjectParams<K extends keyof SlotMap & string, H> =
ScopeOf<K> extends 'session'
? ([H] extends [StoreDecl] ? [sessionId: SessionIdOf, actions: BoundActions<HandleOf<H>>] : [sessionId: SessionIdOf])
: ([H] extends [StoreDecl] ? [actions: BoundActions<HandleOf<H>>] : [])
/** Kind shape fields carried in register options (keyed dispatch key; list id/order/label). */
export type KindOptions<E extends SlotEntryDef> =
E['kind'] extends 'keyed' ? { key: string }
: E['kind'] extends 'list' ? { id: string; order?: number; label?: string }
: object
/**
* Compile-time presence check: an entry declaring children MUST consume
* `renderSlot` (declaring is claiming — an entry that does not render its
* children should not declare them). Evaluates to an unsatisfiable
* intersection member naming the declared keys when violated.
*/
type RendersCheck<C, D> =
[keyof D & keyof SlotMap & string] extends [never] ? unknown
: C extends (props: infer P) => ReactNode
? ('renderSlot' extends keyof P ? unknown
: { 'children declared but the component consumes no renderSlot': keyof D & keyof SlotMap & string })
: unknown
/** Common register options share (see {@link SlotCore.register} for semantics). */
type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H> = {
/** Target slot key (the entry contributes INTO this slot). */
name: K
/** Child-slot declaration + render authorization + runtime spec, in one table. */
children?: D
/** Store seat: a shared handle (apply-constructed) or an exclusive factory (framework-called per entry x scope). */
store?: H
/** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */
registrant?: string
} & KindOptions<SlotMap[K]>
/**
* One stored registration, as recorded by the core and read by the render
* machinery (type-erased at this boundary; the register seam already proved
* the shares against the component).
*/
export interface StoredEntry {
component: unknown
options: { key?: string; id?: string; order?: number; label?: string; inject?: unknown }
options: { key?: string; id?: string; order?: number; label?: string }
/** Registrant business face; positional params derive from the declaration (sessionId?, actions?). */
inject?: ((...args: never[]) => Record<string, unknown>) | undefined
/** Child-slot declaration table (declaration + authorization + runtime spec in one). */
children?: Readonly<Record<string, SlotSpec<SlotEntryDef>>> | undefined
/** Declared store seat (instance resolution and lifecycle live with the host machinery). */
store?: StoreDecl | undefined
/** Diagnostics label of who registered. */
registrant?: string | undefined
}
/** Per-key registry record. Created on first define/subscribe/version read; never removed (version stays monotonic across redefines). */
/**
* Type-erased options view the implementation works with. Optional members
* carry explicit `| undefined`: under exactOptionalPropertyTypes the public
* overloads (whose generics admit undefined) would otherwise fail
* overload-to-implementation compatibility.
*/
interface ErasedOptions {
name: string
key?: string | undefined
id?: string | undefined
order?: number | undefined
label?: string | undefined
children?: Record<string, SlotSpec<SlotEntryDef>> | undefined
store?: StoreDecl | undefined
/* eslint-disable-next-line @typescript-eslint/no-explicit-any --
* implementation-signature position only (both public overloads type inject
* exactly); `never[]` would fail overload-to-implementation compatibility
* against the per-declaration InjectParams tuples. */
inject?: ((...args: any) => Record<string, unknown>) | undefined
registrant?: string | undefined
}
/**
* Per-key registry record. Created on first touch; never removed (version
* stays monotonic across redeclarations).
*/
interface SlotRecord {
spec: SlotSpec<SlotEntryDef> | undefined
/** Diagnostics: which slot's entry declared this key ('(built-in)' for root). */
declaredBy: string | undefined
entries: readonly StoredEntry[]
version: number
listeners: Set<() => void>
@@ -173,8 +238,11 @@ interface SlotRecord {
const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
/**
* Pure slot registry (no cordis; event emission lives in the runtime Service
* wrapper via {@link SlotCore.onMutate}).
* Pure slot registry (no cordis; event emission and the renderer install seam
* live in the runtime Service wrapper).
*
* The 'root' slot is the one a-priori declaration, seeded at construction
* (single/root, declared by the framework) — the render tree's root hole.
*
* Change propagation contract: versions bump and {@link SlotCore.onMutate}
* fires synchronously per mutation (registry state is consistent when they
@@ -184,101 +252,177 @@ const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
export class SlotCore {
private records = new Map<string, SlotRecord>()
private mutateListeners = new Set<(key: string) => void>()
/** Shared-handle scope ledger: handle → the scope it first mounted under + live mount count. */
private handleScopes = new Map<object, { scope: SlotScope; count: number }>()
// Dirty records, not keys: records are never removed, so holding the
// reference skips a lookup (and an unreachable missing-record branch) at flush.
private dirty = new Set<SlotRecord>()
private flushScheduled = false
constructor() {
// The a-priori root hole. No markDirty: nothing can observe construction.
const root = this.record('root')
root.spec = { kind: 'single', scope: 'root' }
root.declaredBy = '(built-in)'
}
/**
* Record a slot's runtime spec. Registering into an undefined key throws;
* defining an already-defined key throws (one owner per slot).
* @param key - SlotMap key.
* @param spec - kind/scope spec matching the declaration.
* @returns disposer removing the definition and its entries (idempotent; a
* stale disposer after redefine is a no-op).
* Contribute a component to a declared slot and (optionally) declare child
* slots, a store seat, and the registrant's business face — the single
* composition API (the separate define API is retired).
*
* Load-time validation (misconfiguration fails loud; the render hot path
* re-checks nothing): registering into an undeclared slot throws; declaring
* an already-declared child key throws (one declarer per slot — the message
* names the first declarer); mounting one shared store handle under slots
* of different scopes throws. Kind constraints: single — duplicate
* registration throws; keyed — missing/duplicate `key` throws; list —
* missing/duplicate `id` throws.
*
* Lifecycle: the disposer removes the contribution AND collapses every
* declared child slot (child entries clear recursively; their stale
* disposers become no-ops) — one lifecycle axis, no dangling state.
*
* @param options - registration options: target `name`, `children`
* declaration table, `store` seat, `inject` business-face factory, kind
* shape fields (keyed `key`; list `id`/`order`/`label`).
* @param component - component honoring the four-share composed props
* contract ({@link ComposedProps}); checked at this call site.
* @returns disposer removing the registration and its declarations
* (idempotent; stale disposers after a cascade are no-ops).
*/
define<K extends keyof SlotMap & string>(key: K, spec: SlotSpec<SlotMap[K]>): () => void {
const rec = this.record(key)
if (rec.spec) throw new Error(`slot "${String(key)}" is already defined`)
const recorded: SlotSpec<SlotEntryDef> = spec
rec.spec = recorded
this.markDirty(key, rec)
register<
K extends keyof SlotMap & string,
const D extends ChildrenDecl = Record<never, never>,
H extends StoreDecl | undefined = undefined,
C extends SlotComponent<never> = SlotComponent<never>,
>(
options: BaseOptions<K, D, H> & { inject?: undefined },
component: C
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object>>
& RendersCheck<C, D>,
): () => void
register<
K extends keyof SlotMap & string,
I extends object,
const D extends ChildrenDecl = Record<never, never>,
H extends StoreDecl | undefined = undefined,
C extends SlotComponent<never> = SlotComponent<never>,
>(
options: BaseOptions<K, D, H> & { inject: (...args: InjectParams<K, H>) => I },
component: C
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I>>
& RendersCheck<C, D>,
): () => void
register(options: ErasedOptions, component: unknown): () => void {
const rec = this.records.get(options.name)
if (!rec?.spec) {
throw new Error(`slot "${options.name}" is not declared (a parent entry's children table must declare it)`)
}
const spec = rec.spec
// Kind constraints stay runtime checks for dynamically-composed callers;
// typed callers already satisfied KindOptions statically.
switch (spec.kind) {
case 'single':
if (rec.entries.length > 0) throw new Error(`single slot "${options.name}" already has a registration`)
break
case 'keyed':
if (options.key === undefined) throw new Error(`keyed slot "${options.name}" requires options.key`)
if (rec.entries.some(e => e.options.key === options.key)) {
throw new Error(`keyed slot "${options.name}" already has an entry for key "${options.key}"`)
}
break
case 'list':
if (options.id === undefined) throw new Error(`list slot "${options.name}" requires options.id`)
if (rec.entries.some(e => e.options.id === options.id)) {
throw new Error(`list slot "${options.name}" already has an entry with id "${options.id}"`)
}
break
}
if (options.children) {
for (const childKey of Object.keys(options.children)) {
const childRec = this.records.get(childKey)
if (childRec?.spec) {
throw new Error(`slot "${childKey}" is already declared (by ${childRec.declaredBy ?? 'an unknown entry'})`)
}
}
}
// Shared handles pin their scope on first mount; factories are exempt
// (the framework creates per-entry instances, no shared identity exists).
if (options.store !== undefined && typeof options.store !== 'function') {
const pinned = this.handleScopes.get(options.store)
if (pinned && pinned.scope !== spec.scope) {
throw new Error(
`store handle mounted under "${options.name}" (scope "${spec.scope}") is already mounted under scope "${pinned.scope}" — one handle, one scope`)
}
if (pinned) pinned.count += 1
else this.handleScopes.set(options.store, { scope: spec.scope, count: 1 })
}
const entry: StoredEntry = {
component,
options: {
...(options.key !== undefined ? { key: options.key } : {}),
...(options.id !== undefined ? { id: options.id } : {}),
...(options.order !== undefined ? { order: options.order } : {}),
...(options.label !== undefined ? { label: options.label } : {}),
},
...(options.inject !== undefined ? { inject: options.inject } : {}),
...(options.children !== undefined ? { children: options.children } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(options.registrant !== undefined ? { registrant: options.registrant } : {}),
}
const next = [...rec.entries, entry]
// Stable sort: order ascending, ties keep registration sequence.
if (spec.kind === 'list') next.sort((a, b) => (a.options.order ?? 0) - (b.options.order ?? 0))
rec.entries = next
this.markDirty(options.name, rec)
if (options.children) {
for (const [childKey, childSpec] of Object.entries(options.children)) {
const childRec = this.record(childKey)
childRec.spec = childSpec
childRec.declaredBy = `an entry in "${options.name}"${options.registrant ? ` (${options.registrant})` : ''}`
this.markDirty(childKey, childRec)
}
}
return () => {
if (rec.spec !== recorded) return
rec.spec = undefined
rec.entries = NO_ENTRIES
this.markDirty(key, rec)
if (!rec.entries.includes(entry)) return
rec.entries = rec.entries.filter(e => e !== entry)
this.markDirty(options.name, rec)
this.releaseEntry(entry)
}
}
/**
* Contribute a component to a defined slot. single: duplicate registration
* throws; keyed: missing or duplicate `options.key` throws; list: missing or
* duplicate `options.id` throws (duplicates would make `only`/`entryKey`
* dispatch ambiguous).
* @param key - SlotMap key.
* @param component - component honoring the entry's composed props contract (owner & standard & injected shares).
* @param args - kind-shaped registration options; statically mandatory for keyed/list (key/id live there).
* @returns disposer removing the registration (idempotent; a stale disposer
* after the slot's define disposer ran is a no-op).
* Whether a previously obtained entry is still registered (the render
* machinery's stale-authorization probe: a retained renderSlot binding
* whose entry left the ledger must not render).
* @param entry - a previously read entry.
* @returns false once the entry's registration was disposed.
*/
register<K extends keyof SlotMap & string, I extends object = Record<string, unknown>, Ctx = unknown>(
// NoInfer pins I's inference to the inject factory: letting the component
// position drive I would absorb any props drift into the constraint.
// Ctx flows from the options' inject-factory parameter annotation
// (narrowing wrappers fix it; the core stays context-agnostic).
key: K, component: SlotComponent<ComposedProps<K, NoInfer<I>>>, ...args: RegisterArgs<SlotMap[K], I, Ctx>): () => void {
const rec = this.records.get(key)
if (!rec?.spec) throw new Error(`slot "${String(key)}" is not defined`)
const opts = (args[0] ?? {}) as StoredEntry['options']
// keyed/list options are statically mandatory (RegisterArgs); the runtime
// checks below stay for dynamically-composed callers.
switch (rec.spec.kind) {
case 'single':
if (rec.entries.length > 0) throw new Error(`single slot "${String(key)}" already has a registration`)
break
case 'keyed':
if (opts.key === undefined) throw new Error(`keyed slot "${String(key)}" requires options.key`)
if (rec.entries.some(e => e.options.key === opts.key)) {
throw new Error(`keyed slot "${String(key)}" already has an entry for key "${opts.key}"`)
}
break
case 'list':
if (opts.id === undefined) throw new Error(`list slot "${String(key)}" requires options.id`)
if (rec.entries.some(e => e.options.id === opts.id)) {
throw new Error(`list slot "${String(key)}" already has an entry with id "${opts.id}"`)
}
break
}
const entry: StoredEntry = { component, options: opts }
const next = [...rec.entries, entry]
// Stable sort: order ascending, ties keep registration sequence.
if (rec.spec.kind === 'list') next.sort((a, b) => (a.options.order ?? 0) - (b.options.order ?? 0))
rec.entries = next
this.markDirty(key, rec)
return () => {
if (!rec.entries.includes(entry)) return
rec.entries = rec.entries.filter(e => e !== entry)
this.markDirty(key, rec)
isLive(entry: StoredEntry): boolean {
for (const rec of this.records.values()) {
if (rec.entries.includes(entry)) return true
}
return false
}
/**
* Snapshot the registered entries for a key. Returns the cached array
* reference (stable between mutations — safe as a uSES getSnapshot source);
* empty for keys not (or no longer) defined, so renderers may probe ahead of
* plugin load order.
* @param key - SlotMap key.
* empty for keys not (or no longer) declared, so renderers may probe ahead
* of plugin load order.
* @param key - slot key (dynamic: the render machinery holds keys as strings).
* @returns entries in registration (list: order) sequence.
*/
entries<K extends keyof SlotMap & string>(key: K): readonly SlotEntry<SlotMap[K]>[] {
return (this.records.get(key)?.entries ?? NO_ENTRIES) as unknown as readonly SlotEntry<SlotMap[K]>[]
entries(key: string): readonly StoredEntry[] {
return this.records.get(key)?.entries ?? NO_ENTRIES
}
/**
* Look up a slot's defined spec, narrowed by the SlotMap key.
* Look up a slot's declared spec, narrowed by the SlotMap key.
* @param key - SlotMap key.
* @returns the spec, or undefined before define.
* @returns the spec, or undefined while undeclared.
*/
spec<K extends keyof SlotMap & string>(key: K): SlotSpec<SlotMap[K]> | undefined {
return this.records.get(key)?.spec as SlotSpec<SlotMap[K]> | undefined
@@ -289,7 +433,7 @@ export class SlotCore {
* only hold as strings (generic dispatch) use this wide form; statically
* keyed callers use {@link SlotCore.spec}.
* @param key - candidate slot key.
* @returns the wide-typed spec, or undefined before define.
* @returns the wide-typed spec, or undefined while undeclared.
*/
specDynamic(key: string): SlotSpec<SlotEntryDef> | undefined {
return this.records.get(key)?.spec
@@ -297,12 +441,12 @@ export class SlotCore {
/**
* Subscribe to registration changes for a key (microtask-batched).
* Subscribing ahead of define is allowed; the define itself notifies.
* @param key - SlotMap key.
* Subscribing ahead of declaration is allowed; the declaration notifies.
* @param key - slot key.
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(key: keyof SlotMap & string, fn: () => void): () => void {
subscribe(key: string, fn: () => void): () => void {
const rec = this.record(key)
rec.listeners.add(fn)
return () => { rec.listeners.delete(fn) }
@@ -311,10 +455,10 @@ export class SlotCore {
/**
* Monotonic version for a key, bumped synchronously per mutation so a
* uSES getSnapshot read is never stale when its batched notification lands.
* @param key - SlotMap key.
* @param key - slot key.
* @returns current version (0 for untouched keys).
*/
getVersion(key: keyof SlotMap & string): number {
getVersion(key: string): number {
return this.records.get(key)?.version ?? 0
}
@@ -330,10 +474,35 @@ export class SlotCore {
return () => { this.mutateListeners.delete(fn) }
}
/**
* Cascade for a removed entry: release its store mount and collapse every
* child slot it declared — specs clear, contributions empty (their stale
* disposers no-op), recursively down the declaration tree. One lifecycle
* axis: ledger rows, slots, contributions, and store mounts die together.
*/
private releaseEntry(entry: StoredEntry): void {
if (entry.store !== undefined && typeof entry.store !== 'function') {
const pinned = this.handleScopes.get(entry.store)
if (pinned && --pinned.count === 0) this.handleScopes.delete(entry.store)
}
if (!entry.children) return
for (const childKey of Object.keys(entry.children)) {
const childRec = this.records.get(childKey)
/* v8 ignore next -- defensive: declaring always creates the record */
if (!childRec) continue
const doomed = childRec.entries
childRec.spec = undefined
childRec.declaredBy = undefined
childRec.entries = NO_ENTRIES
this.markDirty(childKey, childRec)
for (const dead of doomed) this.releaseEntry(dead)
}
}
private record(key: string): SlotRecord {
let rec = this.records.get(key)
if (!rec) {
rec = { spec: undefined, entries: NO_ENTRIES, version: 0, listeners: new Set() }
rec = { spec: undefined, declaredBy: undefined, entries: NO_ENTRIES, version: 0, listeners: new Set() }
this.records.set(key, rec)
}
return rec
@@ -359,49 +528,3 @@ export class SlotCore {
}
}
}
/**
* Whitelist-narrowed render surface handed to owner components via props
* (implementation lives in web-react's scopedSlots factory).
*/
export interface ScopedSlots<K extends keyof SlotMap & string> {
/**
* Render a slot's registered entries.
* @param key - whitelisted SlotMap key.
* @param props - owner-supplied share of the entry's props contract.
* @param opts - render options.
* @returns rendered node(s).
*/
renderSlot: <Key extends K>(key: Key, props: OwnerOf<Key>, opts?: RenderOpts) => ReactNode
/**
* Phantom variance anchor (never materialized at runtime): generic method
* signatures compare loosely across differing key-union constraints, so
* this contravariant marker is what actually enforces "a surface is
* assignable only where its whitelist covers the target's keys".
*/
readonly __accepts?: ((key: K) => void) | undefined
}
/** renderSlot options: keyed dispatch key, list filtering, empty fallback. */
export interface RenderOpts { entryKey?: string; only?: string; fallback?: ReactNode }
/**
* Narrow a slots surface to a subset whitelist for delegation to a child
* component (`K2` ⊆ `K1`). Pure type narrowing — ScopedSlots is covariant in
* its key union, so the same object is returned.
* @param slots - the owner's wider surface.
* @returns the same surface, typed to the subset.
*/
export function narrowSlots<K2 extends K1, K1 extends keyof SlotMap & string>(
slots: ScopedSlots<K1>): ScopedSlots<K2> {
return slots
}
/**
* The owner-supplied share of an entry's props. `useSession` is excluded (the
* framework injects it on session slots; owners must not shadow the bound
* hook). Registrant inject keys are per-registration and unknowable at the
* type level, so the remaining share stays Partial rather than exact.
*/
export type OwnerProps<E extends SlotEntryDef> =
E extends { props: infer P extends object } ? Partial<Omit<P, 'useSession'>> : object
+116
View File
@@ -0,0 +1,116 @@
/**
* Renderer install seam (slot terminal design §8): the SlotRenderer interface
* web-react's machinery implements, the host surface the runtime SlotsService
* presents to the installed renderer, and the render-path authorization
* errors. Pure types plus two error classes — this package stays React-free
* at runtime (React types only).
*/
import type { ReactNode } from 'react'
import type { SlotEntryDef, SlotSpec, StoredEntry } from './index.ts'
/** Minimal observable surface for host-provided standard-kit data sources. */
export interface HostObservable<T> {
getSnapshot(): T
subscribe(fn: () => void): () => void
}
/**
* 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}.
*/
export interface StoreInstanceLike {
readonly useSelector: unknown
readonly actions: Record<string, (...params: never[]) => void>
}
/** Session standard kit resolved per session id (identity-stable per session scope; a recreated scope yields a new cell). */
export interface SessionCell {
sessionId: string
/** Bound conversation-snapshot selector hook (wide here; runtime narrows at its export seam). */
useSession: unknown
}
/** renderSlot dispatch options at the machinery level: keyed dispatch key, list filtering, empty fallback. */
export interface RenderOpts {
entryKey?: string
only?: string
fallback?: ReactNode
}
/** Host surface the runtime SlotsService presents to the installed renderer. */
export interface SlotRendererHost {
/**
* Subscribe to a key's registration changes (microtask-batched).
* @param key - slot key.
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(key: string, fn: () => void): () => void
/**
* Monotonic version for uSES pairing.
* @param key - slot key.
* @returns current version.
*/
getVersion(key: string): number
/**
* Snapshot the registered entries for a key (stable reference between mutations).
* @param key - slot key.
* @returns entries in registration (list: order) sequence.
*/
entriesOf(key: string): readonly StoredEntry[]
/**
* Declared runtime spec from the declarations ledger.
* @param key - slot key.
* @returns the spec, or undefined while the key is undeclared (outlets render empty).
*/
specOf(key: string): SlotSpec<SlotEntryDef> | undefined
/**
* Stale-authorization check: whether the entry is still in the ledger.
* @param entry - a previously rendered entry.
* @returns false once the entry's registration was disposed.
*/
isLive(entry: StoredEntry): boolean
/**
* Resolve (create or return cached) the store instance for an entry's
* declared handle under a scope key; lifecycle rides the ledger axis.
* @param entry - entry whose declaration carries the handle.
* @param scopeKey - session id for session-scope slots, undefined for root scope.
* @returns the instance, or undefined when the entry declares no store.
*/
storeOf(entry: StoredEntry, scopeKey: string | undefined): StoreInstanceLike | undefined
/** Session-side standard-kit sources. */
sessions: {
/** Session list source backing the useSessions standard hook. */
list: HostObservable<unknown>
/** Current-session source backing SessionProvider's self-wiring (design fiat ①). */
current: HostObservable<string | undefined>
/**
* Resolve the session standard kit.
* @param id - session id.
* @returns the cell, or undefined for an unknown session (provider falls to empty).
*/
cell(id: string): SessionCell | undefined
}
}
/** The install seam: runtime owns install()/renderSlot(); web-react implements rendering. */
export interface SlotRenderer {
/**
* Render the root slot tree over the host surface (the only ctx-level entry).
* @param host - the installing service's host surface.
* @param ownerProps - owner props from the shell's renderSlot('root', ...) call.
* @returns the rendered tree.
*/
renderRoot(host: SlotRendererHost, ownerProps: object): ReactNode
}
/** Thrown when a retained renderSlot binding is invoked after its declaring entry was disposed. */
export class StaleAuthorizationError extends Error {}
/**
* Thrown when a renderSlot binding is invoked for a key outside its entry's
* children declaration (plain-JS backstop; typed callers are narrowed
* statically).
*/
export class SlotOwnershipError extends Error {}
+137
View File
@@ -0,0 +1,137 @@
/**
* Store-seat type family (slot terminal design §4): a registrant declares its
* shared/exclusive business store as data — schema (`init`), optional
* persistence key, and the complete write set (`actions`) — and the framework
* owns instance lifecycle (scope derives from the mounting entry's slot).
* ui-slots ships the contract types only; the engine-backed `defineStore`
* value lives in web-react (the snapshot-store engine's home) and must
* satisfy {@link DefineStore}.
*/
/**
* Typed selector hook over a snapshot source. Canonical shape for the whole
* slot system (web-react's engine hook is structurally identical; the
* framework is the only party that ever constructs one).
*/
export type SnapshotSelectorHook<T> = <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S
/**
* Action declaration table: pure immer-draft transforms over the store state,
* declared as the store's complete write set (the audit face — components can
* only write through these).
*/
/* eslint-disable-next-line @typescript-eslint/no-explicit-any --
* any[] (not unknown[]): each action carries its own parameter list, and
* unknown[] would reject every concrete signature under strict parameter
* contravariance. Params are re-inferred per action by BakedActions. */
export type ActionsDecl<T> = Record<string, (draft: T, ...params: any[]) => void>
/**
* Draft-stripped callback form of an actions table: what components
* (`props.actions`) and inject factories receive — the framework bakes the
* draft parameter away by binding each action to the resolved instance.
*/
export type BakedActions<T, A extends ActionsDecl<T>> = {
[K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never
}
/**
* Store declaration spec: initial-state factory (a lambda so every instance
* gets a fresh state), optional persistence key (mechanical, framework-run),
* and the actions write set.
*/
export interface StoreSpec<T, A extends ActionsDecl<T>> {
/** Initial-state factory; called once per framework-created instance. */
init: () => T
/** Opt-in persistence key (storage mechanics belong to the engine). */
persist?: string
/** Complete write set: pure draft transforms. */
actions: A
}
/**
* 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.
*/
export interface StoreInstance<T, A extends ActionsDecl<T>> {
/** Selector hook bound to this instance (delivered to components as `useStore`). */
readonly useSelector: SnapshotSelectorHook<T>
/** Baked write callbacks (delivered to components as `actions`). */
readonly actions: BakedActions<T, A>
/** Current state snapshot (test assertions; machinery). */
getSnapshot(): T
/**
* Subscribe to state changes.
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void
/**
* Drop this instance's persisted value (no-op for non-persist specs). The
* framework calls it when the owning scope dies for good — a pruned session
* must not leave orphaned storage keys behind.
*/
clearPersisted(): void
}
/**
* Store handle: spec + state/actions types + shared identity + instance
* factory in one value. Handles are constructed in apply world (shared across
* registrations of one plugin) or by the framework from a registrant's
* factory (exclusive). Never export a handle at module level — module-cache
* identity is a disguised singleton across plugin reloads.
*/
export interface StoreHandle<T, A extends ActionsDecl<T>> {
/** The inert declaration this handle was defined from. */
readonly spec: StoreSpec<T, A>
/**
* Create a live engine instance (framework machinery and tests only).
* @param scopeKey - session id for session-scope instances; suffixes the
* persist key so per-session instances persist independently (root-scope
* instances omit it).
* @returns a fresh instance seeded from `spec.init()`.
*/
create(scopeKey?: string): StoreInstance<T, A>
}
/**
* Exclusive-store registration form: the registrant passes the factory itself
* and the framework calls it per entry x scope (no shared identity exists).
*/
/* eslint-disable-next-line @typescript-eslint/no-explicit-any --
* erased position accepting every StoreHandle instantiation; T/A are
* recovered per use site by conditional inference (HandleOf/BoundActions/
* PropsStore). */
export type StoreFactory = () => StoreHandle<any, any>
/** The register `store` option position: a shared handle or an exclusive factory. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- same erased-constraint position as StoreFactory (see above).
export type StoreDecl = StoreHandle<any, any> | StoreFactory
/** Normalize a store declaration to its handle type (factories yield their return). */
export type HandleOf<H> = H extends () => infer R ? R : H
/**
* Handle-keyed baked actions: the `actions` parameter of an inject factory
* whose registration declared a store — the same baked callback set the
* component receives via {@link PropsStore}.
*/
export type BoundActions<H> = H extends StoreHandle<infer T, infer A> ? BakedActions<T, A> : never
/**
* The store props share, derived from the declared handle: a typed selector
* hook plus the baked write set. Components never see the instance itself
* (no update/set — reads via useStore, writes via the declared actions only).
*/
export type PropsStore<H> = H extends StoreHandle<infer T, infer A>
? { useStore: SnapshotSelectorHook<T>; actions: BakedActions<T, A> }
: object
/**
* The defineStore contract (implementation lives in web-react, bound to the
* snapshot-store engine): spec in, handle out, with T inferred from `init`
* and the actions table constrained by T.
*/
export type DefineStore = <T, A extends ActionsDecl<T>>(spec: StoreSpec<T, A>) => StoreHandle<T, A>
+208 -112
View File
@@ -1,165 +1,290 @@
// SlotCore terminal-design behavior: the single register composition API —
// a-priori 'root', children declaration/authorization, load-time validation,
// one-axis lifecycle cascade, store scope pinning, subscription surface.
import { describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import type { RootBinding, SessionBinding, SlotOptions } from '@deepseek-ai/dsh-client-ui-slots'
import type { SlotComponent, StoreHandle } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
// 'root' is NOT merged here: the runtime package owns the built-in row, and
// the client aggregate program would see both merges collide.
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'test.single': { kind: 'single'; scope: 'root'; props: { label: string } }
'test.list': { kind: 'list'; scope: 'root'; props: { label: string } }
'test.keyed': { kind: 'keyed'; scope: 'session'; props: { label: string; useSession: unknown } }
'test.single': { kind: 'single'; scope: 'root' }
'test.session': { kind: 'single'; scope: 'session' }
'test.list': { kind: 'list'; scope: 'root' }
'test.keyed': { kind: 'keyed'; scope: 'session' }
'test.grandchild': { kind: 'single'; scope: 'root' }
}
}
const Comp: FC<{ label: string }> = () => null
const SessionComp: FC<{ label: string; useSession: unknown }> = () => null
// Wide-accepting fixture: assignable wherever the composed constraint is an
// object type (children-declaring fixtures erase via `as never` instead —
// RendersCheck would demand a renderSlot consumer).
const Comp: SlotComponent<object> = () => null
/** A minimal structurally-valid store handle (identity is what the ledger tracks). */
function fakeHandle(): StoreHandle<{ n: number }, Record<string, (d: { n: number }) => void>> {
return {
spec: { init: () => ({ n: 0 }), actions: {} },
create: () => { throw new Error('not under test') },
}
}
/** Register a root-frame entry declaring the four test child slots. */
function mountFrame(core: SlotCore) {
return core.register({
name: 'root',
children: {
'test.single': { kind: 'single', scope: 'root' },
'test.session': { kind: 'single', scope: 'session' },
'test.list': { kind: 'list', scope: 'root' },
'test.keyed': { kind: 'keyed', scope: 'session' },
},
// Type-level renderSlot presence is proven by the type-chain spec; erasing
// here keeps runtime fixtures terse.
}, Comp as never)
}
const flushMicrotasks = () => new Promise<void>((resolve) => { queueMicrotask(resolve) })
describe('SlotCore kind semantics', () => {
it('throws on register before define', () => {
describe('a-priori root and declaration gate', () => {
it('seeds root as single/root at construction', () => {
const core = new SlotCore()
expect(() => core.register('test.single', Comp)).toThrow('not defined')
expect(core.specDynamic('root')).toEqual({ kind: 'single', scope: 'root' })
})
it('throws on duplicate define', () => {
it('throws on registering into an undeclared slot', () => {
const core = new SlotCore()
core.define('test.single', { kind: 'single', scope: 'root' })
expect(() => core.define('test.single', { kind: 'single', scope: 'root' })).toThrow('already defined')
expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('not declared')
})
it('single: second registration throws, disposer frees the seat', () => {
it('root is single: a second frame registration throws', () => {
const core = new SlotCore()
core.define('test.single', { kind: 'single', scope: 'root' })
const dispose = core.register('test.single', Comp)
expect(() => core.register('test.single', Comp)).toThrow('already has a registration')
dispose()
mountFrame(core)
expect(() => core.register({ name: 'root' }, Comp)).toThrow('already has a registration')
})
it('children declaration makes child slots registerable, with specs recorded', () => {
const core = new SlotCore()
mountFrame(core)
expect(core.specDynamic('test.session')).toEqual({ kind: 'single', scope: 'session' })
expect(() => core.register({ name: 'test.single' }, Comp)).not.toThrow()
})
it('duplicate child declaration throws naming the first declarer', () => {
const core = new SlotCore()
mountFrame(core)
core.register({ name: 'test.single', children: { 'test.grandchild': { kind: 'single', scope: 'root' } } }, Comp as never)
expect(() => core.register(
{ name: 'test.session', children: { 'test.grandchild': { kind: 'single', scope: 'root' } }, registrant: 'imposter' },
Comp as never,
)).toThrow(/already declared.*test\.single/)
})
})
describe('lifecycle cascade (one axis)', () => {
it('disposing a declaring entry collapses child slots and their contributions recursively', () => {
const core = new SlotCore()
const disposeFrame = mountFrame(core)
const disposeChild = core.register(
{ name: 'test.single', children: { 'test.grandchild': { kind: 'single', scope: 'root' } } }, Comp as never)
core.register({ name: 'test.grandchild' }, Comp)
expect(core.entries('test.grandchild')).toHaveLength(1)
disposeFrame()
expect(core.specDynamic('test.single')).toBeUndefined()
expect(core.specDynamic('test.grandchild')).toBeUndefined()
expect(core.entries('test.single')).toHaveLength(0)
expect(() => core.register('test.single', Comp)).not.toThrow()
expect(core.entries('test.grandchild')).toHaveLength(0)
// Stale disposer of a cascaded-away entry is a no-op.
expect(() => { disposeChild() }).not.toThrow()
// Slots return to undeclared: contributing again throws until redeclared.
expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('not declared')
})
it('registration disposers are idempotent', () => {
const core = new SlotCore()
const dispose = mountFrame(core)
dispose()
dispose()
expect(core.entries('root')).toHaveLength(0)
// Redeclare works after collapse.
mountFrame(core)
expect(core.specDynamic('test.single')).toBeDefined()
})
it('isLive tracks ledger membership across dispose', () => {
const core = new SlotCore()
mountFrame(core)
const dispose = core.register({ name: 'test.single' }, Comp)
const entry = core.entries('test.single')[0]!
expect(core.isLive(entry)).toBe(true)
dispose()
expect(core.isLive(entry)).toBe(false)
})
})
describe('kind semantics', () => {
it('keyed: duplicate key throws, missing key throws', () => {
const core = new SlotCore()
core.define('test.keyed', { kind: 'keyed', scope: 'session' })
core.register('test.keyed', SessionComp, { key: 'a' })
expect(() => core.register('test.keyed', SessionComp, { key: 'a' })).toThrow('key "a"')
// Statically rejected since RegisterArgs made keyed options mandatory;
// the runtime guard stays for dynamically-composed callers.
// @ts-expect-error keyed registration requires options
expect(() => core.register('test.keyed', SessionComp)).toThrow('requires options.key')
expect(() => core.register('test.keyed', SessionComp, { key: 'b' })).not.toThrow()
mountFrame(core)
core.register({ name: 'test.keyed', key: 'a' }, Comp)
expect(() => core.register({ name: 'test.keyed', key: 'a' }, Comp)).toThrow('key "a"')
// Statically rejected (KindOptions); runtime guard stays for dynamic callers.
// @ts-expect-error keyed registration requires options.key
expect(() => core.register({ name: 'test.keyed' }, Comp)).toThrow('requires options.key')
expect(() => core.register({ name: 'test.keyed', key: 'b' }, Comp)).not.toThrow()
})
it('list: duplicate id throws, missing id throws, entries sort by order stably', () => {
const core = new SlotCore()
core.define('test.list', { kind: 'list', scope: 'root' })
core.register('test.list', Comp, { id: 'c', order: 10 })
core.register('test.list', Comp, { id: 'a' })
core.register('test.list', Comp, { id: 'b' })
expect(() => core.register('test.list', Comp, { id: 'a' })).toThrow('id "a"')
// @ts-expect-error list registration requires options (static since RegisterArgs)
expect(() => core.register('test.list', Comp)).toThrow('requires options.id')
const ids = core.entries('test.list').map(e => (e.options as { id: string }).id)
expect(ids).toEqual(['a', 'b', 'c'])
mountFrame(core)
core.register({ name: 'test.list', id: 'c', order: 10 }, Comp)
core.register({ name: 'test.list', id: 'a' }, Comp)
core.register({ name: 'test.list', id: 'b' }, Comp)
expect(() => core.register({ name: 'test.list', id: 'a' }, Comp)).toThrow('id "a"')
// @ts-expect-error list registration requires options.id
expect(() => core.register({ name: 'test.list' }, Comp)).toThrow('requires options.id')
expect(core.entries('test.list').map(e => e.options.id)).toEqual(['a', 'b', 'c'])
})
it('spec() exposes the definition; define disposer clears spec and entries', () => {
it('single: second registration throws, disposer frees the seat', () => {
const core = new SlotCore()
const dispose = core.define('test.single', { kind: 'single', scope: 'root' })
core.register('test.single', Comp)
expect(core.spec('test.single')).toEqual({ kind: 'single', scope: 'root' })
mountFrame(core)
const dispose = core.register({ name: 'test.single' }, Comp)
expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('already has a registration')
dispose()
expect(core.spec('test.single')).toBeUndefined()
expect(core.entries('test.single')).toHaveLength(0)
expect(() => core.register('test.single', Comp)).toThrow('not defined')
})
it('disposers are idempotent and stale disposers after redefine are no-ops', () => {
const core = new SlotCore()
const disposeDef = core.define('test.single', { kind: 'single', scope: 'root' })
const disposeReg = core.register('test.single', Comp)
disposeReg()
disposeReg()
disposeDef()
disposeDef()
core.define('test.single', { kind: 'single', scope: 'root' })
core.register('test.single', Comp)
disposeDef()
disposeReg()
expect(core.spec('test.single')).toBeDefined()
expect(core.entries('test.single')).toHaveLength(1)
expect(() => core.register({ name: 'test.single' }, Comp)).not.toThrow()
})
})
describe('SlotCore subscription surface', () => {
describe('store scope pinning', () => {
it('one shared handle under two scopes throws at load', () => {
const core = new SlotCore()
mountFrame(core)
const handle = fakeHandle()
core.register({ name: 'test.session', store: handle }, Comp as never)
expect(() => core.register({ name: 'test.single', store: handle }, Comp as never))
.toThrow('one handle, one scope')
})
it('same handle under same scope is fine; full unmount releases the pin', () => {
const core = new SlotCore()
mountFrame(core)
const handle = fakeHandle()
const d1 = core.register({ name: 'test.list', id: 'x', store: handle }, Comp as never)
const d2 = core.register({ name: 'test.list', id: 'y', store: handle }, Comp as never)
d1()
// Still mounted once — scope stays pinned.
expect(() => core.register({ name: 'test.session', store: handle }, Comp as never))
.toThrow('one handle, one scope')
d2()
// All mounts gone: the handle may pin a new scope.
expect(() => core.register({ name: 'test.session', store: handle }, Comp as never)).not.toThrow()
})
it('factories are exempt from pinning (no shared identity)', () => {
const core = new SlotCore()
mountFrame(core)
const factory = () => fakeHandle()
core.register({ name: 'test.session', store: factory }, Comp as never)
expect(() => core.register({ name: 'test.single', store: factory }, Comp as never)).not.toThrow()
})
it('cascade releases store pins of collapsed child entries', () => {
const core = new SlotCore()
const disposeFrame = mountFrame(core)
const handle = fakeHandle()
core.register({ name: 'test.session', store: handle }, Comp as never)
disposeFrame()
mountFrame(core)
expect(() => core.register({ name: 'test.single', store: handle }, Comp as never)).not.toThrow()
})
})
describe('subscription surface', () => {
it('entries() returns a stable cached reference between mutations', () => {
const core = new SlotCore()
core.define('test.list', { kind: 'list', scope: 'root' })
core.register('test.list', Comp, { id: 'a' })
mountFrame(core)
core.register({ name: 'test.list', id: 'a' }, Comp)
const first = core.entries('test.list')
expect(core.entries('test.list')).toBe(first)
core.register('test.list', Comp, { id: 'b' })
core.register({ name: 'test.list', id: 'b' }, Comp)
expect(core.entries('test.list')).not.toBe(first)
})
it('bumps version synchronously but batches notifications per microtask', async () => {
const core = new SlotCore()
mountFrame(core)
const fn = vi.fn()
core.subscribe('test.list', fn)
core.define('test.list', { kind: 'list', scope: 'root' })
core.register('test.list', Comp, { id: 'a' })
core.register('test.list', Comp, { id: 'b' })
expect(core.getVersion('test.list')).toBe(3)
const before = core.getVersion('test.list')
core.register({ name: 'test.list', id: 'a' }, Comp)
core.register({ name: 'test.list', id: 'b' }, Comp)
expect(core.getVersion('test.list')).toBe(before + 2)
expect(fn).not.toHaveBeenCalled()
await flushMicrotasks()
expect(fn).toHaveBeenCalledTimes(1)
core.register('test.list', Comp, { id: 'c' })
core.register({ name: 'test.list', id: 'c' }, Comp)
await flushMicrotasks()
expect(fn).toHaveBeenCalledTimes(2)
})
it('declaration itself notifies child-key subscribers (subscribe-ahead allowed)', async () => {
const core = new SlotCore()
const fn = vi.fn()
core.subscribe('test.single', fn)
mountFrame(core)
await flushMicrotasks()
expect(fn).toHaveBeenCalledTimes(1)
})
it('notifies only subscribers of the touched key; unsubscribe stops delivery', async () => {
const core = new SlotCore()
mountFrame(core)
await flushMicrotasks()
const single = vi.fn()
const list = vi.fn()
core.subscribe('test.single', single)
const unsubscribe = core.subscribe('test.list', list)
core.define('test.single', { kind: 'single', scope: 'root' })
core.register({ name: 'test.single' }, Comp)
await flushMicrotasks()
expect(single).toHaveBeenCalledTimes(1)
expect(list).not.toHaveBeenCalled()
unsubscribe()
core.define('test.list', { kind: 'list', scope: 'root' })
core.register({ name: 'test.list', id: 'a' }, Comp)
await flushMicrotasks()
expect(list).not.toHaveBeenCalled()
})
it('a mutation from inside a flush re-schedules instead of being lost', async () => {
const core = new SlotCore()
core.define('test.list', { kind: 'list', scope: 'root' })
mountFrame(core)
await flushMicrotasks()
const seen: number[] = []
let reentered = false
core.subscribe('test.list', () => {
seen.push(core.getVersion('test.list'))
if (!reentered) {
reentered = true
core.register('test.list', Comp, { id: 'reentrant' })
core.register({ name: 'test.list', id: 'reentrant' }, Comp)
}
})
core.register('test.list', Comp, { id: 'a' })
core.register({ name: 'test.list', id: 'a' }, Comp)
await flushMicrotasks()
await flushMicrotasks()
expect(seen).toHaveLength(2)
expect(core.entries('test.list')).toHaveLength(2)
})
it('getVersion is 0 for untouched keys and monotonic across redefine', () => {
it('getVersion is 0 for untouched keys and monotonic across redeclaration', () => {
const core = new SlotCore()
expect(core.getVersion('test.single')).toBe(0)
const dispose = core.define('test.single', { kind: 'single', scope: 'root' })
const dispose = mountFrame(core)
dispose()
const after = core.getVersion('test.single')
core.define('test.single', { kind: 'single', scope: 'root' })
mountFrame(core)
expect(core.getVersion('test.single')).toBeGreaterThan(after)
})
@@ -167,43 +292,14 @@ describe('SlotCore subscription surface', () => {
const core = new SlotCore()
const keys: string[] = []
const off = core.onMutate(key => keys.push(key))
core.define('test.single', { kind: 'single', scope: 'root' })
core.define('test.list', { kind: 'list', scope: 'root' })
core.register('test.list', Comp, { id: 'a' })
expect(keys).toEqual(['test.single', 'test.list', 'test.list'])
mountFrame(core)
// Contribution first, then each declared child key.
expect(keys).toEqual(['root', 'test.single', 'test.session', 'test.list', 'test.keyed'])
keys.length = 0
core.register({ name: 'test.list', id: 'a' }, Comp)
expect(keys).toEqual(['test.list'])
off()
core.register('test.list', Comp, { id: 'b' })
expect(keys).toHaveLength(3)
})
})
describe('SlotOptions typing', () => {
it('rejects kind-mismatched options and scope-mismatched inject bindings', () => {
// Compile-time negatives only: the body never runs (some rejected shapes
// would be legal at runtime, which validates kinds, not props).
const typeNegatives = (core: SlotCore) => {
// @ts-expect-error single options take no key
core.register('test.single', Comp, { key: 'x' })
// @ts-expect-error list options require id
core.register('test.list', Comp, { order: 1 })
// @ts-expect-error keyed options require key
core.register('test.keyed', SessionComp, { inject: () => ({}) })
// @ts-expect-error component props must match the SlotMap contract
core.register('test.single', SessionComp)
// @ts-expect-error kind must match the SlotMap declaration
core.define('test.single', { kind: 'list', scope: 'root' })
const rootInject: SlotOptions<{ kind: 'single'; scope: 'root'; props: { label: string } }> = {
// @ts-expect-error root slots bind RootBinding, which has no sessionId
inject: (b: RootBinding) => ({ sessionId: b.sessionId }),
}
return rootInject
}
expect(typeNegatives).toBeTypeOf('function')
const sessionInject: SlotOptions<{ kind: 'keyed'; scope: 'session'; props: { label: string } }> = {
key: 'k',
inject: (b: SessionBinding) => ({ sessionId: b.sessionId }),
}
expect(sessionInject.key).toBe('k')
core.register({ name: 'test.list', id: 'b' }, Comp)
expect(keys).toHaveLength(1)
})
})
+18 -28
View File
@@ -1,24 +1,31 @@
// Dynamic-key escape hatches and untouched-key behavior of the terminal core.
import { describe, expect, it } from 'vitest'
import type { FC } from 'react'
import type { ScopedSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { narrowSlots, SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
import type { SlotComponent } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'surface.a': { kind: 'single'; scope: 'root'; props: { label: string } }
'surface.b': { kind: 'single'; scope: 'root'; props: { label: string } }
'surface.a': { kind: 'single'; scope: 'root' }
'surface.b': { kind: 'single'; scope: 'root' }
}
}
const Comp: FC<{ label: string }> = () => null
const Comp: SlotComponent<object> = () => null
describe('dynamic-key escape hatch', () => {
it('specDynamic reads wide-typed specs for string keys; undefined before define', () => {
it('specDynamic reads wide-typed specs for string keys; undefined while undeclared', () => {
const core = new SlotCore()
expect(core.specDynamic('surface.a')).toBeUndefined()
core.define('surface.a', { kind: 'single', scope: 'root' })
core.register({ name: 'root', children: { 'surface.a': { kind: 'single', scope: 'root' } } }, Comp as never)
expect(core.specDynamic('surface.a')).toEqual({ kind: 'single', scope: 'root' })
expect(core.specDynamic('never.defined')).toBeUndefined()
expect(core.specDynamic('never.declared')).toBeUndefined()
})
it('spec() narrows by SlotMap key', () => {
const core = new SlotCore()
core.register({ name: 'root', children: { 'surface.a': { kind: 'single', scope: 'root' } } }, Comp as never)
expect(core.spec('surface.a')).toEqual({ kind: 'single', scope: 'root' })
expect(core.spec('surface.b')).toBeUndefined()
})
it('entries/getVersion on an untouched key return the frozen empty array and 0', () => {
@@ -27,26 +34,9 @@ describe('dynamic-key escape hatch', () => {
expect(core.entries('surface.b')).toBe(core.entries('surface.b'))
expect(core.getVersion('surface.b')).toBe(0)
})
})
describe('narrowSlots', () => {
it('returns the same surface narrowed to the subset whitelist', () => {
const wide: ScopedSlots<'surface.a' | 'surface.b'> = { renderSlot: () => null }
const narrow = narrowSlots<'surface.a', 'surface.a' | 'surface.b'>(wide)
expect(narrow).toBe(wide)
const rejects = (s: ScopedSlots<'surface.a'>) => {
// @ts-expect-error 'surface.b' is outside the narrowed whitelist
return () => s.renderSlot('surface.b', {})
}
expect(rejects(narrow)).toBeTypeOf('function')
})
})
describe('registration typing', () => {
it('keyed/list registrations statically require options (runtime guard retained)', () => {
it('isLive is false for entries the core never held', () => {
const core = new SlotCore()
core.define('surface.a', { kind: 'single', scope: 'root' })
// single: options omissible.
expect(() => core.register('surface.a', Comp)).not.toThrow()
expect(core.isLive({ component: Comp, options: {} })).toBe(false)
})
})
+155 -123
View File
@@ -1,140 +1,172 @@
// Slot type-chain negative samples (design.md §9 item 2) plus the slots-ring
// full-chain positive: register→inject→render composed under the ownership
// rule (owner share referenced, injected share locally declared).
// Terminal-design compile-time samples (design.md §11 item 2): the four-share
// composed register constraint — children spec x SlotMap alignment, renderSlot
// key-set containment, store share matching, inject face completeness — plus
// the full positive chain. Bodies with @ts-expect-error sites never run.
import { describe, expect, it } from 'vitest'
import type { FC, ReactNode } from 'react'
import type { ReactNode } from 'react'
import type {
OwnerOf, RootBinding, ScopedSlots, SessionBinding, SlotMap, SlotOptions,
BoundActions, DefineStore, PropsRenderSlots, PropsRuntime, PropsStore, SlotComponent,
} from '@deepseek-ai/dsh-client-ui-slots'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
/** Owner share as the slot-owning package's contract would export it. */
interface ChainOwnerShare { sessionId: string }
/** Registrant's own injected share (locally declared — ownership rule). */
interface ChainInjected { useThing: () => number; actions: { open: () => void } }
// Only package-unique SlotMap keys are merged here. The standard-kit
// interfaces (SessionStandardProps/GlobalStandardProps) are NOT re-merged:
// the runtime package owns the real members, and in the client aggregate
// program a toy merge would collide with them — samples below stay
// shape-agnostic about kit member payloads for the same reason.
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'chain.session': { kind: 'single'; scope: 'session'; props: ChainSessionProps; owner: ChainOwnerShare }
'chain.root': { kind: 'single'; scope: 'root'; props: ChainRootProps; owner: object }
'chain.keyed': { kind: 'keyed'; scope: 'root'; props: ChainRootProps; owner: object }
'chain.frame': { kind: 'single'; scope: 'root' }
'chain.side': { kind: 'single'; scope: 'root'; owner: { collapsed: boolean; width: number } }
'chain.conv': { kind: 'single'; scope: 'session' }
'chain.tools': { kind: 'keyed'; scope: 'session' }
}
}
/** Full props = owner share (referenced) + standard share + own injected. */
type ChainSessionProps = ChainOwnerShare & { useSession: unknown } & ChainInjected
type ChainRootProps = ChainInjected
declare const defineStore: DefineStore
const SessionComp: FC<ChainSessionProps> = () => null
const RootComp: FC<ChainRootProps> = () => null
describe('type-chain negatives (compile-time; bodies never run)', () => {
it('holds the six negative samples as expect-error sites', () => {
const negatives = (core: SlotCore, slots: ScopedSlots<'chain.session'>) => {
// 1. Owner passing a registrant-injected key through renderSlot.
// OwnerOf<'chain.session'> = ChainOwnerShare — useThing is not in it.
slots.renderSlot('chain.session', {
sessionId: 's1',
// @ts-expect-error injected keys are not owner-suppliable
useThing: () => 1,
})
// 2. Inject factory returning a share that mismatches the registrant's
// declared own-injected slice (missing `actions`). The I-typed
// options form is where the mismatch surfaces (the full composed
// register constraint lands with the phase-2 consumer migration).
const mismatched: SlotOptions<SlotMap['chain.session'], ChainInjected> = {
// @ts-expect-error inject must supply the full registrant share
inject: () => ({ useThing: () => 1 }),
}
void mismatched
// 3. renderSlot on a key outside the whitelist.
// @ts-expect-error 'chain.root' is not whitelisted on this surface
slots.renderSlot('chain.root', {})
// 4. keyed registration without options.
// @ts-expect-error keyed kind requires options (RegisterArgs)
core.register('chain.keyed', RootComp)
// 5. Session-slot inject factory typed against RootBinding's surface.
const sessionOpts: SlotOptions<{ kind: 'single'; scope: 'session'; props: ChainSessionProps }, ChainInjected> = {
// @ts-expect-error session binding has sessionId; RootBinding-only factories don't type-check
inject: (b: RootBinding & { notSession: true }) => ({ useThing: () => 1, actions: { open: () => {} } }),
}
void sessionOpts
// 6. Hand-copied owner share drifting from the contract (wrong value type)
// — the composed-reference version right below compiles instead.
interface DriftedProps { sessionId: number }
const Drifted: FC<DriftedProps & ChainInjected> = () => null
// @ts-expect-error drifted hand-copy of the owner share fails at register
core.register('chain.session', Drifted)
return null as ReactNode
}
expect(negatives).toBeTypeOf('function')
/** Factory form (exclusive seat): module-level export, never a handle. */
function createPanelStore() {
return defineStore({
init: () => ({ sidebar: 280, details: 0 }),
persist: 'test.panels',
actions: {
setSidebar: (d, px: number) => { d.sidebar = px },
setDetails: (d, px: number) => { d.details = px },
},
})
it('full chain (positive dual): composed props register, inject, and render cleanly', () => {
const core = new SlotCore()
core.define('chain.session', { kind: 'single', scope: 'session' })
const dispose = core.register('chain.session', SessionComp, {
inject: (b: SessionBinding): ChainInjected => ({
useThing: () => b.sessionId.length,
actions: { open: () => {} },
}),
})
const entry = core.entries('chain.session')[0]!
// Storage erasure boundary: entries() returns the default-I view; the
// registrant share is restored after read-back (the budgeted cast).
const injected = (entry.options.inject as unknown as (b: SessionBinding) => ChainInjected)(
{ sessionId: 's1', session: { useSelector: undefined }, ctx: undefined })
expect(injected.useThing()).toBe(2)
// Owner share stays reference-typed at the render surface.
const ownerShare: OwnerOf<'chain.session'> = { sessionId: 's1' }
expect(ownerShare.sessionId).toBe('s1')
dispose()
expect(core.entries('chain.session')).toHaveLength(0)
})
})
// ── children validation layer (B-b, opt-in per entry) ───────────────────────
/** Delegating owner share: entry declares children, component carries a slots face. */
interface DelegOwnerShare { sessionId: string }
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'chain.deleg': { kind: 'single'; scope: 'root'; props: object; owner: DelegOwnerShare; children: 'chain.child-a' | 'chain.child-b' }
'chain.child-a': { kind: 'single'; scope: 'root'; props: object; owner: object }
'chain.child-b': { kind: 'single'; scope: 'root'; props: object; owner: object }
'chain.outside': { kind: 'single'; scope: 'root'; props: object; owner: object }
}
}
describe('children validation layer (compile-time; bodies never run)', () => {
it('accepts whitelists inside the authorized union, rejects outside keys', () => {
const cases = (core: SlotCore) => {
// Positive: slots face ⊆ children union (a strict subset is fine).
const InUnion: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.child-a'> }> = () => null
core.register('chain.deleg', InUnion)
// Positive: the full authorized union.
const FullUnion: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.child-a' | 'chain.child-b'> }> = () => null
core.register('chain.deleg', FullUnion)
// Positive: no slots face at all — delegation is optional.
const NoSlots: FC<DelegOwnerShare> = () => null
core.register('chain.deleg', NoSlots)
// Negative: a key outside the authorized union collapses the slots constraint.
const Outside: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.outside'> }> = () => null
// @ts-expect-error slots whitelist must stay inside the entry's children union
core.register('chain.deleg', Outside)
// Negative: smuggling an extra key alongside authorized ones still fails.
const Mixed: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.child-a' | 'chain.outside'> }> = () => null
// @ts-expect-error a partially-authorized whitelist is still out of union
core.register('chain.deleg', Mixed)
// Entries WITHOUT children stay unchecked: any slots face registers
// freely (I inferred from the inject factory as usual).
const FreeFace: FC<ChainOwnerShare & { useSession: unknown } & ChainInjected & { slots: ScopedSlots<'chain.outside'> }> = () => null
core.register('chain.session', FreeFace, {
inject: (): ChainInjected & { slots: ScopedSlots<'chain.outside'> } =>
({ useThing: () => 1, actions: { open: () => {} }, slots: { renderSlot: () => null } }),
})
const chatStore = () => defineStore({
init: () => ({ selection: null as { id: string } | null, draft: '' }),
actions: {
select: (d, t: { id: string }) => { d.selection = t },
setDraft: (d, text: string) => { d.draft = text },
clearDraft: (d) => { d.draft = '' },
},
})
type ChatHandle = ReturnType<typeof chatStore>
type FrameProps =
& PropsRuntime<'chain.frame'>
& PropsRenderSlots<'chain.side' | 'chain.conv'>
& PropsStore<ReturnType<typeof createPanelStore>>
& { openSettings: () => void }
type ConvProps =
& PropsRuntime<'chain.conv'>
& PropsStore<ChatHandle>
& { send: (t: string) => void }
// Component fixtures (never rendered; the register call sites are the test).
declare function Frame(props: FrameProps): ReactNode
declare function Conv(props: ConvProps): ReactNode
declare function Details(props: PropsRuntime<'chain.conv'> & PropsStore<ChatHandle>): ReactNode
declare function Tool(props: PropsRuntime<'chain.tools'>): ReactNode
declare function Over(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'chain.side' | 'chain.conv'>): ReactNode
declare function NoDecl(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'chain.side'>): ReactNode
declare function Blind(props: PropsRuntime<'chain.frame'>): ReactNode
declare function WrongStore(props: PropsRuntime<'chain.conv'> & PropsStore<ReturnType<typeof createPanelStore>>): ReactNode
declare function Needs(props: PropsRuntime<'chain.conv'> & { send: (t: string) => void }): ReactNode
describe('terminal-design type chain', () => {
it('holds the positive chain and the compile-time negatives', () => {
// Everything below is compile-surface only.
const samples = (core: SlotCore, chat: ChatHandle, fp: FrameProps, cp: ConvProps, acts: BoundActions<ChatHandle>) => {
// ── positive chain ─────────────────────────────────────────────
// Frame: children + factory store + inject; actions arrive baked.
core.register({
name: 'chain.frame',
children: {
'chain.side': { kind: 'single', scope: 'root' },
'chain.conv': { kind: 'single', scope: 'session' },
},
store: createPanelStore,
inject: (actions) => {
actions.setSidebar(0)
return { openSettings: () => {} }
},
}, Frame)
// Conv: shared handle; inject params derive as (sessionId, actions).
core.register({
name: 'chain.conv',
store: chat,
inject: (sessionId, actions) => ({
send: (text: string) => {
const sid: string = sessionId
actions.setDraft(text)
void sid
},
}),
}, Conv)
// Pure reader: same handle, no inject.
core.register({ name: 'chain.conv', store: chat }, Details)
// Owner + store shares arrive typed on the component face. Standard-kit
// member payloads are the runtime merge's property — not probed here
// (the runtime package's own tests cover them).
fp.renderSlot('chain.side', { collapsed: false, width: 280 })
const draft: string = cp.useStore((s) => s.draft)
cp.actions.select({ id: 'm1' })
void draft
// Keyed registration carries key.
core.register({ name: 'chain.tools', key: 'bash' }, Tool)
// ── negatives ──────────────────────────────────────────────────
// children spec must match the SlotMap entry.
core.register({
name: 'chain.frame',
// @ts-expect-error chain.conv is session-scoped in SlotMap
children: { 'chain.conv': { kind: 'single', scope: 'root' } },
}, (() => null) as SlotComponent<never>)
// renderSlot key set ⊄ children declaration.
// @ts-expect-error component renderSlot keys exceed the declaration
core.register({ name: 'chain.frame', children: { 'chain.side': { kind: 'single', scope: 'root' } } }, Over)
// renderSlot consumption without any children declaration.
// @ts-expect-error no children declaration authorizes rendering
core.register({ name: 'chain.frame' }, NoDecl)
// children declared but component consumes no renderSlot.
// @ts-expect-error children declared, component consumes no renderSlot
core.register({ name: 'chain.frame', children: { 'chain.side': { kind: 'single', scope: 'root' } } }, Blind)
// store share mismatch.
// @ts-expect-error component's store share doesn't match the declared handle
core.register({ name: 'chain.conv', store: chat }, WrongStore)
// inject face incomplete for the component's business share.
// @ts-expect-error inject face missing `send`
core.register({ name: 'chain.conv', inject: () => ({ notSend: 1 }) }, Needs)
// @ts-expect-error nothing provides `send` (no inject at all)
core.register({ name: 'chain.conv' }, Needs)
// root-scope inject takes no sessionId.
core.register({
name: 'chain.side',
// @ts-expect-error root-scope inject has no sessionId parameter
inject: (sessionId: string) => ({ x: sessionId }),
}, ((_p) => null) as SlotComponent<PropsRuntime<'chain.side'> & { x: string }>)
// keyed registration without key.
// @ts-expect-error keyed registration requires options.key
core.register({ name: 'chain.tools' }, Tool)
// renderSlot owner share typed at the call site.
// @ts-expect-error owner shape mismatch (width missing)
fp.renderSlot('chain.side', { collapsed: false })
// @ts-expect-error key not in this render share
fp.renderSlot('chain.tools', {})
// baked actions strip the draft parameter.
acts.setDraft('x')
// @ts-expect-error wrong payload type
acts.setDraft(1)
}
expect(cases).toBeTypeOf('function')
expect(samples).toBeTypeOf('function')
})
})
@@ -2,21 +2,24 @@
/**
* View registration acceptance on the real framework stack: the plugin fiber
* registers trajectory/waterfall into a real ConversationService, tabs switch
* inside ConversationRoot without collapsing chat, chrome.header renders the
* span stats bar, and fiber disposal removes both tabs. Span derivation edge
* cases ride along.
* inside ConversationRoot (four-share props form; view rendering is
* in-component now) without collapsing chat, chrome.header renders the span
* stats bar, and fiber disposal removes both tabs. Span derivation edge cases
* ride along.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createElement, Fragment, type FC, type ReactNode } from 'react'
import { bindSnapshotSelector, createSnapshotStore } from '@deepseek-ai/dsh-client-web-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 type { ConversationSnapshot, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
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.
import { ConversationRoot } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationRoot.tsx'
import type { ConvViewProps, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
import type { ConvViewProps, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { deriveSpans, deriveSpanStats } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
import { TrajectoryStatsHeader } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryStatsHeader.tsx'
@@ -27,6 +30,11 @@ import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
const SID = 's1' as SessionId
afterEach(cleanup)
// The chat store persists under its declared key; clear so one case's active
// view cannot rehydrate into the next.
beforeEach(() => {
localStorage.clear()
})
/** Node fixture: user prologue, two turns, one tool result inside turn 1. */
const NODES = [
@@ -38,7 +46,25 @@ const NODES = [
function fakeSession(nodes: ConversationSnapshot['nodes']) {
const store = createSnapshotStore<{ nodes: ConversationSnapshot['nodes'] }>({ nodes })
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession }
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
/** Empty sessions-list hook stub (breadcrumbs fall back to the raw id). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)
return store.useSelector
}
/** Chat-view stand-in props for standalone view mounts. */
function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
const chat = createChatStore().create()
return {
sessionId: SID,
useSession: fakeSession(nodes).useSession,
useStore: chat.useSelector,
actions: { openDetails: vi.fn(), loadOlder: vi.fn() },
} as unknown as ConvViewProps
}
/** Real-stack bench: root Context + real ConversationService + the plugin fiber. */
@@ -52,45 +78,29 @@ async function bench() {
return { ctx, svc, fiber }
}
/** Mount ConversationRoot over the service's registry face, rendering chrome like the conversation apply does. */
/** Mount ConversationRoot over the service's registry face (four-share form: chrome/view rendering is in-component). */
function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] = NODES) {
const { useSession } = fakeSession(nodes)
const activeStore = createSnapshotStore<string | undefined>(undefined)
const ancestry: SessionSummary[] = [{ id: SID, title: 'self', running: false, updatedAt: 1 }]
const viewProps = {
sessionId: SID, useSession,
useSelection: () => null,
actions: { openDetails: vi.fn(), loadOlder: vi.fn() },
slots: undefined,
} as unknown as ConvViewProps
const renderView = (entry: ViewEntry): ReactNode => {
const children: ReactNode[] = []
if (entry.chrome?.header !== undefined) {
children.push(createElement(entry.chrome.header, { key: 'h', sessionId: SID, useSession }))
}
children.push(createElement(entry.component, { key: 'b', ...viewProps }))
if (entry.chrome?.footer !== undefined) {
children.push(createElement(entry.chrome.footer, { key: 'f', sessionId: SID, useSession }))
}
return createElement(Fragment, null, children)
}
const sessionSnapshot = createSnapshotStore<{ running: boolean; removed: boolean; promptError: null; nodes: ConversationSnapshot['nodes'] }>({
running: false, removed: false, promptError: null, nodes,
})
const chat = createChatStore().create()
return render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSnapshot) as unknown as UseSession}
useAncestry={() => ancestry}
useSession={bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>}
useSessions={emptySessions()}
useStore={chat.useSelector}
actions={chat.actions}
views={{
list: () => svc.views(),
subscribe: (fn) => svc.subscribeViews(fn),
version: () => svc.viewsVersion(),
}}
useActiveView={() => activeStore.useSelector((s) => s) as ViewId | undefined}
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
actions={{ openView: ((v: string) => { activeStore.set(v) }) as (v: never) => void, open: vi.fn() }}
renderView={renderView}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
loadOlder={vi.fn()}
open={vi.fn()}
/>,
)
}
@@ -167,28 +177,22 @@ describe('span derivation', () => {
const { useSession } = fakeSession([] as unknown as ConversationSnapshot['nodes'])
const { container } = render(createElement(TrajectoryStatsHeader, { sessionId: SID, useSession }))
expect(container.firstChild).toBeNull()
render(createElement(TrajectoryView as FC<ConvViewProps>, {
sessionId: SID, useSession, useSelection: () => null,
actions: { openDetails: vi.fn(), loadOlder: vi.fn() }, slots: undefined,
} as unknown as ConvViewProps))
render(createElement(TrajectoryView as FC<ConvViewProps>,
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
})
})
describe('WaterfallView standalone branches', () => {
const props = (nodes: ConversationSnapshot['nodes']) => ({
sessionId: SID, useSession: fakeSession(nodes).useSession, useSelection: () => null,
actions: { openDetails: vi.fn(), loadOlder: vi.fn() }, slots: undefined,
} as unknown as ConvViewProps)
it('empty window renders the placeholder copy', () => {
render(createElement(WaterfallView as FC<ConvViewProps>, props([] as unknown as ConversationSnapshot['nodes'])))
render(createElement(WaterfallView as FC<ConvViewProps>,
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
})
it('a turn without tool calls renders the node bar only', () => {
const nodes = [{ kind: 'user', seq: 1 }] as unknown as ConversationSnapshot['nodes']
render(createElement(WaterfallView as FC<ConvViewProps>, props(nodes)))
render(createElement(WaterfallView as FC<ConvViewProps>, standaloneProps(nodes)))
expect(screen.getByTitle('1 nodes')).toBeTruthy()
expect(screen.queryByTitle(/tool calls/)).toBeNull()
})
+3 -3
View File
@@ -1,10 +1,10 @@
# @deepseek-ai/dsh-client-web-react
ctx↔React glue: createSnapshotStore (zustand vanilla + immer + subscribeWithSelector + rafFlush + opt-in persist), bindSnapshotSelector, SessionProvider (dependency-inverted), scopedSlots outlet, RootBindingProvider, useInvoke. Contract: api-contracts v3 §2.
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.
## Model Experience
None, as the ctx↔React glue runs entirely in the browser; nothing here reaches a model request.
None, as the ctx↔React machinery runs entirely in the browser; nothing here reaches a model request.
#### KV Cache effect
@@ -12,6 +12,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **The persist middleware corrupts primitive-state stores** — it object-spreads state on save, so a `SnapshotStore<string>` round-trips as a character map; consumers with primitive state hand-roll persistence instead (ui-conversation drafts is the precedent).
- **The persist middleware corrupts primitive-state stores** — it object-spreads state on save, so a `SnapshotStore<string>` round-trips as a character map; the engine hand-rolls persistence instead (see `attachPersistence`).
- **`UseSession` is deliberately wide (`object` snapshot)** — the dependency direction (runtime → web-react, never the reverse) keeps the real `ConversationSnapshot` type out of reach; session-slot consumers narrow once at their boundary.
- **renderSlot is the single P-I form** — no Suspense, no per-entry lazy loading; the progressive-rendering surface returns with its own project.
+21 -21
View File
@@ -1,14 +1,22 @@
/**
* ctx-to-React glue: uSES bridge, SessionProvider (dependency-inverted),
* scopedSlots outlet factory, useInvoke. Contract: api-contracts v3 section 2.
* ctx-to-React machinery (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.
*/
import type { ReactNode } from 'react'
import type { SnapshotSelectorHook } from './store/index.ts'
// -- 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 { createSnapshotStore, shallowEqual } from './store/index.ts'
export { defineStore, shallowEqual } from './store/index.ts'
export { bindSnapshotSelector } from './bind.ts'
/**
@@ -19,23 +27,15 @@ export { bindSnapshotSelector } from './bind.ts'
*/
export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap>
/** Session assembly handle narrowed from ui-slots' structural form. */
export interface SessionBinding<Snap extends object = object> {
readonly sessionId: string
readonly session: { useSelector: UseSession<Snap> }
readonly ctx: unknown
}
// -- renderer: the install-seam implementation; contract lives in ui-slots --
export type {
HostObservable, RenderOpts, SessionCell,
SlotRenderer, SlotRendererHost, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
export { SlotOwnershipError, StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots'
export { createSlotRenderer } from './scoped-slots.tsx'
/** SessionProvider dependency surface (inverted: web-react never imports runtime). */
export interface SessionProviderDeps {
useCurrent: () => string | undefined
resolveBinding: (id: string) => SessionBinding | undefined
/** Assembler-owned body: the shell closes over its own scopedSlots to render the session slots. */
renderBody: (id: string) => ReactNode
}
export { createSessionProvider, RootBindingProvider, SlotAssemblyError, useRootBinding, useSessionBinding } from './session-provider.tsx'
export { scopedSlots } from './scoped-slots.tsx'
// -- session area: the framework-wired provider; binding contexts stay internal --
export { SessionProvider, SlotAssemblyError, type SessionProviderProps } from './session-provider.tsx'
export { useInvoke } from './use-invoke.ts'
+161 -101
View File
@@ -1,62 +1,97 @@
/**
* ScopedSlots factory: the sole render surface over the slot registry.
* renderSlot subscribes through uSES (SlotCore.subscribe/getVersion), renders
* per slot kind, wraps every entry in an error boundary, and merges props from
* three sources: standard injection (session slots get useSession), the
* registrant's cached inject factory, then owner props (owner wins).
*
* Typing model (slot type-chain design §4): the key stays generic (`K`) from
* renderSlot down to the outlet, so `entries<K>()` returns typed entries and
* the per-entry render path is monomorphic — no existential casts in loops.
* createSlotRenderer(): the outlet machinery behind the runtime install seam
* (slot terminal design §8). renderRoot mounts the host channel and renders
* the built-in 'root' key; every deeper slot renders through a per-entry
* renderSlot binding synthesized from the entry's children declaration.
* Standard-kit synthesis per entry: the global useSessions hook, the session
* pair (useSession + sessionId) under SessionProvider, the store pair
* (useStore + actions) for store-declaring entries, and the renderSlot
* binding (entry-identity bound, stale-checked) for children-declaring
* entries. Inject factories run inside the entry component bodies ON PURPOSE
* — the per-entry error boundary contains a throwing factory to its own
* entry; parameters follow the declaration (sessionId for session slots,
* baked actions when a store is declared).
*/
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import type {
RenderOpts, RootBinding, ScopedSlots, SessionBinding as SlotSessionBinding,
SlotCore, SlotEntry, SlotMap,
import {
SlotOwnershipError, StaleAuthorizationError,
type RenderOpts, type SessionCell, type SlotRenderer, type SlotRendererHost,
type StoredEntry,
} from '@deepseek-ai/dsh-client-ui-slots'
import { SlotAssemblyError, useRootBinding, useSessionBinding } from './session-provider.tsx'
import {
HostContext, SlotAssemblyError, observableHook, useHost, useSessionCell,
} from './session-provider.tsx'
type AnyKey = keyof SlotMap & string
type EntryOf<K extends AnyKey> = SlotEntry<SlotMap[K]>
type InjectedProps = Record<string, unknown>
/**
* Inject results cache: root slots per entry, session slots per (entry x binding).
* WeakMap keys are the entry objects (stable across entries() snapshots per
* the SlotCore contract); values are the registrant's injected share. Storage
* erases the per-entry `I` — the single budgeted cast per cache restores it.
*/
const rootInjectCache = new WeakMap<object, InjectedProps>()
const sessionInjectCache = new WeakMap<object, WeakMap<object, InjectedProps>>()
/** Owner-facing renderSlot binding shape (typed narrowing lands on the wave-1 props seam). */
type RenderSlotBinding = (key: string, owner: object, opts?: RenderOpts) => ReactNode
function cachedRootInject<K extends AnyKey>(entry: EntryOf<K>, binding: RootBinding): InjectedProps {
const inject = entry.options?.inject
/**
* Per-entry renderSlot bindings. The binding is identity-stable per entry
* (memoized components must not resubscribe on unrelated re-renders) and dies
* with the entry: a retained closure calling after the entry's disposal hits
* the in-ledger check and throws.
*/
const renderSlotCache = new WeakMap<StoredEntry, RenderSlotBinding>()
function boundRenderSlot(host: SlotRendererHost, entry: StoredEntry): RenderSlotBinding {
let binding = renderSlotCache.get(entry)
if (!binding) {
binding = (key, owner, opts) => {
if (!host.isLive(entry)) {
throw new StaleAuthorizationError(`renderSlot('${key}') from a disposed registration`)
}
// Plain-JS backstop; typed callers are narrowed to the declared keys.
if (entry.children?.[key] === undefined) {
throw new SlotOwnershipError(`slot '${key}' is not declared by this entry's children`)
}
return <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} />
}
renderSlotCache.set(entry, binding)
}
return binding
}
/**
* Inject results cache: root entries per entry, session entries per
* (entry x session cell). WeakMap keys are entry/cell objects (both
* identity-stable per registration/session scope), so cache lifetime rides
* the same axes as the values it memoizes.
*/
const rootInjectCache = new WeakMap<StoredEntry, InjectedProps>()
const sessionInjectCache = new WeakMap<StoredEntry, WeakMap<SessionCell, InjectedProps>>()
function runInject(entry: StoredEntry, cell: SessionCell | undefined, actions: object | undefined): InjectedProps {
const inject = entry.inject
if (!inject) return {}
// Declaration-derived positional arguments: sessionId for session scope,
// baked actions when a store is declared.
const args: unknown[] = []
if (cell !== undefined) args.push(cell.sessionId)
if (actions !== undefined) args.push(actions)
return (inject as (...args: unknown[]) => InjectedProps)(...args)
}
function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps {
let props = rootInjectCache.get(entry)
if (!props) {
// Root-scope factories accept RootBinding; the conditional-type parameter
// only fails to dispatch because K is generic here — the outlet's
// spec.scope branch guarantees the scope side (budgeted cast, one per cache).
props = (inject as (b: RootBinding) => InjectedProps)(binding)
props = runInject(entry, undefined, actions)
rootInjectCache.set(entry, props)
}
return props
}
function cachedSessionInject<K extends AnyKey>(entry: EntryOf<K>, binding: SlotSessionBinding): InjectedProps {
const inject = entry.options?.inject
if (!inject) return {}
let perBinding = sessionInjectCache.get(entry)
if (!perBinding) {
perBinding = new WeakMap()
sessionInjectCache.set(entry, perBinding)
function cachedSessionInject(entry: StoredEntry, cell: SessionCell, actions: object | undefined): InjectedProps {
let perCell = sessionInjectCache.get(entry)
if (!perCell) {
perCell = new WeakMap()
sessionInjectCache.set(entry, perCell)
}
let props = perBinding.get(binding)
let props = perCell.get(cell)
if (!props) {
// Same scope-dispatch note as the root cache: the session branch of the
// outlet guarantees this factory's binding side (budgeted cast).
props = (inject as (b: SlotSessionBinding) => InjectedProps)(binding)
perBinding.set(binding, props)
props = runInject(entry, cell, actions)
perCell.set(cell, props)
}
return props
}
@@ -83,68 +118,77 @@ class SlotErrorBoundary extends Component<
}
}
interface OutletProps<K extends AnyKey> {
core: SlotCore
slotKey: K
ownerProps: object
opts?: RenderOpts | undefined
/**
* 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.
*/
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['sessionId'] = cell.sessionId
}
const store = host.storeOf(entry, cell?.sessionId)
if (store !== undefined) {
kit['useStore'] = store.useSelector
kit['actions'] = store.actions
}
if (entry.children !== undefined) {
kit['renderSlot'] = boundRenderSlot(host, entry)
}
return { kit, actions: store?.actions }
}
/**
* One rendered entry: standard injection + cached inject + owner props.
* Inject factories run inside these component bodies ON PURPOSE — the outlet
* wraps every Entry element in the per-entry error boundary, so a throwing
* factory blacks out only its own entry. The three-source merge composes the
* entry's full props contract; TS cannot prove the composition against
* `SlotMap[K]['props']` (the shares are erased at the registry boundary), so
* each Entry renders through a props-widened view of the component — the
* design-budgeted composition point, one per scope branch.
* One rendered entry: standard kit + cached inject + owner props (owner
* wins). The kit and injected shares are erased at the render boundary — the
* register seam already proved the composed contract — so each Entry renders
* through a props-widened view of the component (the design-budgeted
* composition point, one per scope branch).
*/
function SessionEntry<K extends AnyKey>({ entry, ownerProps }: {
entry: EntryOf<K>; ownerProps: object
}) {
const binding = useSessionBinding()
function SessionEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
const host = useHost()
const cell = useSessionCell()
const Comp = entry.component as FC<InjectedProps>
const injected = cachedSessionInject(entry, binding)
return <Comp useSession={binding.session.useSelector} {...injected} {...ownerProps} />
const { kit, actions } = standardKit(host, entry, cell)
const injected = cachedSessionInject(entry, cell, actions)
return <Comp {...kit} {...injected} {...ownerProps} />
}
function RootEntry<K extends AnyKey>({ entry, ownerProps }: {
entry: EntryOf<K>; ownerProps: object
}) {
const hasInject = entry.options?.inject !== undefined
function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
const host = useHost()
const Comp = entry.component as FC<InjectedProps>
// Only inject-bearing entries need the root binding channel; plain entries
// must render fine in shells that never mounted RootBindingProvider.
if (!hasInject) return <Comp {...ownerProps} />
return <RootInjectEntry entry={entry} ownerProps={ownerProps} />
const { kit, actions } = standardKit(host, entry, undefined)
const injected = cachedRootInject(entry, actions)
return <Comp {...kit} {...injected} {...ownerProps} />
}
function RootInjectEntry<K extends AnyKey>({ entry, ownerProps }: {
entry: EntryOf<K>; ownerProps: object
function SlotOutlet({ slotKey, ownerProps, opts }: {
slotKey: string; ownerProps: object; opts?: RenderOpts | undefined
}) {
const binding = useRootBinding()
const Comp = entry.component as FC<InjectedProps>
const injected = cachedRootInject(entry, binding)
return <Comp {...injected} {...ownerProps} />
}
function SlotOutlet<K extends AnyKey>({ core, slotKey, ownerProps, opts }: OutletProps<K>) {
// Version tick drives entries() re-read; SlotCore batches per microtask.
const host = useHost()
// Version tick drives entries() re-read; the host batches per microtask.
useSyncExternalStore(
(fn) => core.subscribe(slotKey, fn),
() => core.getVersion(slotKey),
(fn) => host.subscribe(slotKey, fn),
() => host.getVersion(slotKey),
)
const spec = core.spec(slotKey)
if (!spec) throw new Error(`renderSlot('${slotKey}') before define`)
const entries = core.entries(slotKey)
const Entry: FC<{ entry: EntryOf<K>; ownerProps: object }> =
spec.scope === 'session' ? SessionEntry : RootEntry
const spec = host.specOf(slotKey)
// Undeclared (or no-longer-declared) keys render empty: a declaring entry's
// unload returns the slot to the undeclared state while retained elements
// may still be mounted — natural empty, not an ownership failure (§9).
if (!spec) return null
const entries = host.entriesOf(slotKey)
const Entry = spec.scope === 'session' ? SessionEntry : RootEntry
// The boundary must wrap the Entry ELEMENT, not live inside it: inject
// factories and binding lookups run in the Entry body and must land in the
// factories and kit synthesis run in the Entry body and must land in the
// per-entry fallback rather than escaping to the tree above.
const guarded = (entry: EntryOf<K>, key?: string | number) => (
const guarded = (entry: StoredEntry, key?: string | number) => (
<SlotErrorBoundary slotKey={slotKey} key={key}>
<Entry entry={entry} ownerProps={ownerProps} />
</SlotErrorBoundary>
@@ -156,15 +200,15 @@ function SlotOutlet<K extends AnyKey>({ core, slotKey, ownerProps, opts }: Outle
return guarded(entry)
}
if (spec.kind === 'keyed') {
const entry = entries.find((e) => e.options && 'key' in e.options && e.options.key === opts?.entryKey)
const entry = entries.find((e) => e.options?.key === opts?.entryKey)
if (!entry) return <>{opts?.fallback ?? null}</>
return guarded(entry)
}
// list: registration order refined by explicit order, optional id filter.
const withListOptions = entries.map((entry) => ({
entry,
id: entry.options && 'id' in entry.options ? entry.options.id : undefined,
order: entry.options && 'order' in entry.options ? entry.options.order ?? 0 : 0,
id: entry.options?.id,
order: entry.options?.order ?? 0,
}))
let list = [...withListOptions].sort((a, b) => a.order - b.order)
if (opts?.only !== undefined) list = list.filter((item) => item.id === opts.only)
@@ -172,20 +216,36 @@ function SlotOutlet<K extends AnyKey>({ core, slotKey, ownerProps, opts }: Outle
return <>{list.map((item, i) => guarded(item.entry, item.id ?? i))}</>
}
/** Root outlet: the shell's single ctx-level render entry — an unregistered 'root' is a boot-order failure, never a silent blank (§1). */
function RootOutlet({ ownerProps }: { ownerProps: object }) {
const host = useHost()
useSyncExternalStore(
(fn) => host.subscribe('root', fn),
() => host.getVersion('root'),
)
const entry = host.entriesOf('root')[0]
if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)")
return (
<SlotErrorBoundary slotKey="root">
<RootEntry entry={entry} ownerProps={ownerProps} />
</SlotErrorBoundary>
)
}
/**
* Build a whitelist-narrowed ScopedSlots render surface over a SlotCore.
* The type parameter narrows compile-time access; the runtime whitelist
* backstops plain-JS callers.
* @param core - the slot registry core.
* @param keys - whitelisted slot keys the caller may render.
* @returns the ScopedSlots facade.
* Build the renderer the shell installs into the runtime SlotsService
* (ctx.slots.install(createSlotRenderer()) at boot; the service owns the
* install/renderSlot seam and the double-install/not-installed throws).
* @returns the renderer.
*/
export function scopedSlots<K extends AnyKey>(core: SlotCore, ...keys: K[]): ScopedSlots<K> {
const allowed = new Set<string>(keys)
export function createSlotRenderer(): SlotRenderer {
return {
renderSlot(key, props, opts) {
if (!allowed.has(key)) throw new Error(`slot '${key}' is not in this ScopedSlots whitelist`)
return <SlotOutlet core={core} slotKey={key} ownerProps={props} opts={opts} />
renderRoot(host, ownerProps) {
return (
<HostContext.Provider value={host}>
<RootOutlet ownerProps={ownerProps} />
</HostContext.Provider>
)
},
}
}
@@ -1,15 +1,15 @@
/**
* SessionProvider (dependency-inverted; never imports runtime) plus the two
* binding contexts the slot outlet reads: per-session {@link BindingContext}
* written here, and the root-binding channel written by the shell through
* {@link RootBindingProvider}.
* SessionProvider (framework-wired render prop, slot terminal design §7) plus
* the two internal channels the render machinery shares: the renderer host
* context (written once by createSlotRenderer's root) and the per-session
* binding context (written here, read by session-scope outlets). Both
* contexts are in-package machinery — they are NOT exported from the package
* index; business components see zero React contexts.
*/
import { createContext, useContext, type FC, type ReactNode } from 'react'
import type { RootBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionBinding, SessionProviderDeps } from './index.ts'
/** Session binding for the subtree under SessionProvider (module-private write). */
const BindingContext = createContext<SessionBinding | null>(null)
import { createContext, useContext, type ReactNode } from 'react'
import type { HostObservable, SessionCell, SlotRendererHost } 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
@@ -19,56 +19,75 @@ const BindingContext = createContext<SessionBinding | null>(null)
*/
export class SlotAssemblyError extends Error {}
/** Renderer host channel: written by createSlotRenderer's root element (in-package machinery only). */
export const HostContext = createContext<SlotRendererHost | null>(null)
/**
* Read the enclosing session binding; throws outside a SessionProvider
* subtree (session slots must not render without a session).
* @returns the enclosing binding.
* Read the installed renderer host; throws outside the rendered root tree
* (framework components must not render detached from the renderer).
* @returns the host surface.
*/
export function useSessionBinding(): SessionBinding {
const binding = useContext(BindingContext)
if (!binding) throw new SlotAssemblyError('session slot rendered outside SessionProvider')
return binding
export function useHost(): SlotRendererHost {
const host = useContext(HostContext)
if (!host) throw new SlotAssemblyError('slot machinery rendered outside the installed renderer tree')
return host
}
const RootBindingContext = createContext<RootBinding | null>(null)
/** Per-session binding channel for the subtree under SessionProvider (in-package machinery only). */
const BindingContext = createContext<SessionCell | null>(null)
/**
* Root-binding supply channel: the shell mounts this once at the top so root
* slot inject factories receive their assembly handle.
* Read the enclosing session cell; throws outside a SessionProvider subtree
* (session slots must not render without a session).
* @returns the enclosing cell.
*/
export const RootBindingProvider: FC<{ value: RootBinding; children?: ReactNode }> =
({ value, children }) => (
<RootBindingContext.Provider value={value}>{children}</RootBindingContext.Provider>
)
/**
* Read the root binding; throws when the shell forgot to mount
* {@link RootBindingProvider} (root inject factories need ctx).
* @returns the root binding.
*/
export function useRootBinding(): RootBinding {
const binding = useContext(RootBindingContext)
if (!binding) throw new SlotAssemblyError('root slot inject requires RootBindingProvider above')
return binding
export function useSessionCell(): SessionCell {
const cell = useContext(BindingContext)
if (!cell) throw new SlotAssemblyError('session slot rendered outside SessionProvider')
return cell
}
/**
* Build the single SessionProvider component: subscribes to the current
* session id, resolves its binding (stable reference), remounts the body
* under key={id}, and delegates body rendering to the assembler's renderBody
* (slot ownership stays with layout; the provider knows no slot names).
* @param deps - inverted dependencies.
* @returns the provider component.
* Identity-stable selector hook per host observable. uSES resubscribes when
* the subscribe reference changes, so the bound hook must be created once per
* source — cached here by source identity (sources are host-owned singletons).
* @param source - host-provided observable.
* @returns the cached selector hook.
*/
export function createSessionProvider(deps: SessionProviderDeps): FC<{ renderEmpty?: () => ReactNode }> {
return function SessionProvider({ renderEmpty }) {
const id = deps.useCurrent()
const binding = id === undefined ? undefined : deps.resolveBinding(id)
if (id === undefined || !binding) return <>{renderEmpty?.() ?? null}</>
return (
<BindingContext.Provider value={binding} key={id}>
{deps.renderBody(id)}
</BindingContext.Provider>
)
export function observableHook<T>(source: HostObservable<T>): SnapshotSelectorHook<T> {
let hook = hookCache.get(source)
if (hook === undefined) {
hook = bindSnapshotSelector(source)
hookCache.set(source, hook)
}
return hook as SnapshotSelectorHook<T>
}
const hookCache = new WeakMap<object, unknown>()
/** SessionProvider surface: render-prop body plus the no-session branch. */
export interface SessionProviderProps {
/** No-session body (also covers a current id whose session cannot be resolved). */
empty?: (() => ReactNode) | undefined
/** Session body; remounted per session via key={sessionId}. */
children: (sessionId: string) => ReactNode
}
/**
* Framework-wired session area: subscribes to the host's current-session
* source (design fiat ① — selection authority lives with runtime sessions),
* resolves the session cell, and remounts the body under key={sessionId} so
* a session switch rebuilds the whole session subtree. Ids speak plain
* string at this dependency-inverted layer; branding lands on the component
* props seam (PropsRuntime).
*/
export function SessionProvider({ empty, children }: SessionProviderProps) {
const host = useHost()
const id = observableHook(host.sessions.current)((s) => s)
const cell = id === undefined ? undefined : host.sessions.cell(id)
if (id === undefined || cell === undefined) return <>{empty?.() ?? null}</>
return (
<BindingContext.Provider value={cell} key={id}>
{children(id)}
</BindingContext.Provider>
)
}
+98 -2
View File
@@ -1,14 +1,27 @@
/**
* Snapshot store engine (zustand vanilla + immer + subscribeWithSelector +
* rafFlush middleware + opt-in persist + dev freeze). The only data contract
* consumed by React is {@link ObservableSnapshot}.
* 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.
*/
import { createStore, type StoreApi } from 'zustand/vanilla'
import { subscribeWithSelector } from 'zustand/middleware'
import { shallow } from 'zustand/shallow'
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.
export type {
ActionsDecl, BakedActions, BoundActions, StoreFactory, StoreHandle, StoreInstance, StoreSpec,
} from '@deepseek-ai/dsh-client-ui-slots'
/** Minimal observable snapshot source: Session objects and snapshot stores both satisfy it. */
export interface ObservableSnapshot<T> { getSnapshot(): T; subscribe(fn: () => void): () => void }
@@ -152,3 +165,86 @@ function deepFreeze(value: unknown): void {
deepFreeze((value as Record<PropertyKey, unknown>)[key])
}
}
// ---- defineStore shell (slot terminal design §4) ----
// The type authority is ui-slots' store family (create(scopeKey?) and
// clearPersisted() included); this module houses only the engine-backed
// implementation. The one engine-side widening left: instances expose the
// raw engine store for framework/test surfaces.
/** A live engine instance: the contract instance plus the raw engine store. */
export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {
/** The underlying engine store (framework/test surface; components never see it). */
readonly store: SnapshotStore<T>
}
/** The engine-backed handle: create() narrowed to the engine instance. */
export interface EngineStoreHandle<T, A extends ActionsDecl<T>> extends StoreHandle<T, A> {
/**
* Construct a live engine instance (see the contract JSDoc on
* {@link StoreHandle.create} for scopeKey/persist semantics).
*
* Known boundary: the persist key is the storage identity, so multiple live
* instances created under the same resolved key share (and cross-pollute)
* one localStorage entry. Instance uniqueness per key is the caller's
* responsibility — production is safe because the framework caches one
* instance per handle x scope key; tests wanting isolation use distinct
* scope keys or persist-free declarations (multi-create freedom is a
* feature there, so create() deliberately does not dedupe or throw).
* @param scopeKey - session id for session-scope instances; omitted for root scope.
* @returns the engine instance.
*/
create(scopeKey?: string): EngineStoreInstance<T, A>
}
/**
* Declare a store: initial state, optional persistence, and the full write
* set as pure draft mutators. The returned handle is the registration
* currency of the store seat — its identity keys instance sharing. Satisfies
* ui-slots' DefineStore contract (the handle/instance are the engine-extended
* subtypes).
*
* The `A & ActionsDecl<T>` actions position is load-bearing: T resolves from
* `init` in the first inference round, and the intersection then contextually
* types each mutator's draft parameter (context-sensitive functions defer),
* so call sites write `(d, x: X) => { ... }` with no draft annotation. If a
* future TS version breaks this single-literal inference, the design's
* documented fallback is currying (`defineStore(init).actions({...})`).
* @param decl - init lambda (fresh state per instance), optional persist key, actions table.
* @returns the store handle.
*/
export function defineStore<T, A extends ActionsDecl<T>>(
decl: StoreSpec<T, A> & { actions: A & ActionsDecl<T> }): EngineStoreHandle<T, A> {
return {
spec: decl,
create(scopeKey?: string): EngineStoreInstance<T, A> {
const persistKey = decl.persist === undefined
? undefined
: scopeKey === undefined ? decl.persist : `${decl.persist}.${scopeKey}`
const store = createSnapshotStore<T>(
decl.init(),
persistKey !== undefined ? { persist: { name: persistKey } } : undefined)
const actions = {} as Record<string, (...params: unknown[]) => void>
for (const key of Object.keys(decl.actions)) {
const mutate = decl.actions[key] as (draft: T, ...params: unknown[]) => void
actions[key] = (...params: unknown[]) => { store.update((draft) => { mutate(draft, ...params) }) }
}
return {
useSelector: store.useSelector,
actions: actions as BakedActions<T, A>,
getSnapshot: () => store.getSnapshot(),
subscribe: fn => store.subscribe(fn),
store,
clearPersisted: () => {
if (persistKey === undefined || typeof localStorage === 'undefined') return
try {
localStorage.removeItem(persistKey)
} catch {
// Storage failures (private mode, quota teardown races) only skip
// cleanup — the same non-fatal contract as attachPersistence.
}
},
}
},
}
}
@@ -1,70 +1,121 @@
// @vitest-environment jsdom
/**
* Integration against the real ui-slots SlotCore (T1): the outlet's uSES
* pairing rides the real subscribe/getVersion/entries surfaces, and the
* whitelist narrows at compile time (expect-error negative samples).
* Integration against the real ui-slots SlotCore through a passthrough host:
* registrations go through the real register() (options form, children
* declaration), and the outlets ride the real subscribe/getVersion/entries/
* isLive surfaces — microtask-batched notifications, mutation-stable entry
* references (the cache axis), and ledger-fed stale bindings are the
* real-core semantics the fake-host suite cannot vouch for.
*/
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
import { scopedSlots } from '@deepseek-ai/dsh-client-web-react'
import { SlotCore, type PropsRenderSlots, type SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
import { createSlotRenderer, StaleAuthorizationError } from '@deepseek-ai/dsh-client-web-react'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'spec.single': { kind: 'single'; scope: 'root'; props: { label?: string } }
'spec.list': { kind: 'list'; scope: 'root'; props: object }
'spec.off-limits': { kind: 'single'; scope: 'root'; props: object }
// No 'root' merge: the aggregate client program already carries runtime's
// authoritative 'root' declaration (a private merge would TS2717-collide);
// only this suite's own test keys merge here.
'spec.single': { kind: 'single'; scope: 'root'; owner: { label?: string } }
'spec.list': { kind: 'list'; scope: 'root' }
}
}
describe('scopedSlots over the real SlotCore', () => {
it('renders registrations live: define, register, dispose back to fallback', async () => {
type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'>
/** Passthrough host over the real core (store/session seats unused here). */
function hostOver(core: SlotCore): SlotRendererHost {
return {
subscribe: (key, fn) => core.subscribe(key, fn),
getVersion: (key) => core.getVersion(key),
entriesOf: (key) => core.entries(key),
specOf: (key) => core.specDynamic(key),
isLive: (entry) => core.isLive(entry),
storeOf: () => undefined,
sessions: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
current: { getSnapshot: () => undefined, subscribe: () => () => {} },
cell: () => undefined,
},
}
}
/** Register the root frame (declaring both child keys) and mount the renderer. */
function mountFrame(core: SlotCore, body: (renderSlot: FrameSlots['renderSlot']) => React.ReactNode) {
const dispose = core.register({
name: 'root',
children: {
'spec.single': { kind: 'single', scope: 'root' },
'spec.list': { kind: 'list', scope: 'root' },
},
}, (props: FrameSlots) => <>{body(props.renderSlot)}</>)
const view = render(<>{createSlotRenderer().renderRoot(hostOver(core), {})}</>)
return { view, dispose }
}
describe('createSlotRenderer over the real SlotCore', () => {
it('renders registrations live through real microtask batching: register, dispose back to fallback', async () => {
const core = new SlotCore()
core.define('spec.single', { kind: 'single', scope: 'root' })
const slots = scopedSlots(core, 'spec.single')
const view = render(<>{slots.renderSlot('spec.single', {}, { fallback: <i>none</i> })}</>)
const { view } = mountFrame(core, (renderSlot) =>
renderSlot('spec.single', {}, { fallback: <i>none</i> }))
expect(view.container.textContent).toBe('none')
let dispose = () => {}
// The real core batches subscriber notification per microtask: async act.
await act(async () => { dispose = core.register('spec.single', ({ label }) => <b>{label ?? 'on'}</b>) })
await act(async () => {
dispose = core.register({ name: 'spec.single' }, ({ label }: { label?: string }) => <b>{label ?? 'on'}</b>)
})
expect(view.container.textContent).toBe('on')
await act(async () => { dispose(); dispose() }) // disposer is idempotent in the real core
expect(view.container.textContent).toBe('none')
})
it('passes owner props through and orders list entries', () => {
it('coalesces same-tick mutations into one notification (uSES pairing stays consistent)', async () => {
const core = new SlotCore()
core.define('spec.single', { kind: 'single', scope: 'root' })
core.define('spec.list', { kind: 'list', scope: 'root' })
core.register('spec.single', ({ label }) => <b>{label}</b>)
core.register('spec.list', () => <span>2</span>, { id: 'two', order: 2 })
core.register('spec.list', () => <span>1</span>, { id: 'one', order: 1 })
const slots = scopedSlots(core, 'spec.single', 'spec.list')
const view = render(
<>
{slots.renderSlot('spec.single', { label: 'owner' })}
{slots.renderSlot('spec.list', {})}
</>,
)
expect(view.container.textContent).toBe('owner12')
const notified = vi.fn()
core.subscribe('spec.list', notified)
const { view } = mountFrame(core, (renderSlot) => renderSlot('spec.list', {}))
await act(async () => {
core.register({ name: 'spec.list', id: 'two', order: 2 }, () => <span>2</span>)
core.register({ name: 'spec.list', id: 'one', order: 1 }, () => <span>1</span>)
})
expect(notified).toHaveBeenCalledTimes(1) // two same-tick mutations, one batch
expect(view.container.textContent).toBe('12')
})
it('fails loud when rendering a key that was never defined', () => {
it('passes owner props through and keeps sibling entries() references stable across mutations', async () => {
const core = new SlotCore()
const slots = scopedSlots(core, 'spec.single')
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render(<>{slots.renderSlot('spec.single', {})}</>)).toThrow(/before define/)
spy.mockRestore()
core.register({ name: 'root', children: {
'spec.single': { kind: 'single', scope: 'root' },
'spec.list': { kind: 'list', scope: 'root' },
} }, (props: FrameSlots) => <>
{props.renderSlot('spec.single', { label: 'owner' })}
{props.renderSlot('spec.list', {})}
</>)
core.register({ name: 'spec.single' }, ({ label }: { label?: string }) => <b>{label}</b>)
const view = render(<>{createSlotRenderer().renderRoot(hostOver(core), {})}</>)
expect(view.container.textContent).toBe('owner')
// A mutation on the sibling key leaves this key's entries() reference
// untouched (real-core stability the inject/renderSlot caches key on).
const before = core.entries('spec.single')
await act(async () => { core.register({ name: 'spec.list', id: 'l' }, () => <span>L</span>) })
expect(core.entries('spec.single')).toBe(before)
expect(view.container.textContent).toBe('ownerL')
})
it('narrows the whitelist at compile time and backstops at runtime', () => {
it('feeds stale bindings from the real ledger: a disposed registration throws off isLive', () => {
const core = new SlotCore()
core.define('spec.single', { kind: 'single', scope: 'root' })
core.define('spec.off-limits', { kind: 'single', scope: 'root' })
const slots = scopedSlots(core, 'spec.single')
// @ts-expect-error spec.off-limits is outside this ScopedSlots whitelist
expect(() => slots.renderSlot('spec.off-limits', {})).toThrow(/whitelist/)
// @ts-expect-error unknown keys are rejected even before whitelist narrowing
expect(() => slots.renderSlot('spec.nonexistent', {})).toThrow(/whitelist/)
let captured: FrameSlots['renderSlot'] | undefined
const { view, dispose } = mountFrame(core, (renderSlot) => {
captured = renderSlot
return null
})
expect(captured!('spec.single', {})).not.toBeUndefined() // live binding renders
// Unmount before disposing: an empty 'root' makes a LIVE root outlet
// rethrow boot-order (covered in the fake-host suite); the scenario here
// is a retained closure outliving both tree and registration.
view.unmount()
dispose()
expect(() => captured!('spec.single', {})).toThrow(StaleAuthorizationError)
})
})
@@ -1,142 +1,415 @@
// @vitest-environment jsdom
/**
* createSlotRenderer machinery account over a behavioral fake host: root
* outlet + per-kind child outlets, standard-kit synthesis (renderSlot
* binding, session pair, global useSessions, store pair), inject execution
* point (inside component bodies, contained per entry) and parameter
* derivation, and cache granularity (entry x scope key). Ledger semantics
* (declaration conflicts, store instance accounting) belong to the runtime
* SlotsService suite, not here.
*/
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import type {
FC } from 'react'
import type {
InjectFactory, RootBinding, SlotCore, SlotEntry, SlotEntryDef, SlotSpec,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { ReactNode } from 'react'
import type { SlotEntryDef, SlotSpec, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import {
createSessionProvider, createSnapshotStore, RootBindingProvider, scopedSlots,
type SessionBinding, type SessionProviderDeps,
createSlotRenderer, defineStore, SessionProvider, SlotOwnershipError,
type RenderOpts, type SessionCell,
type SlotRendererHost, type StoreInstanceLike,
} from '@deepseek-ai/dsh-client-web-react'
type AnyProps = Record<string, unknown>
type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode
type DeclaredSpec = SlotSpec<SlotEntryDef>
/** Entry literal helper: fake entries default the mandatory options bag. */
const entryOf = (partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }): StoredEntry =>
({ options: {}, ...partial })
function observable<T>(initial: T) {
let value = initial
const subs = new Set<() => void>()
return {
getSnapshot: () => value,
subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } },
set: (next: T) => { value = next; for (const fn of [...subs]) fn() },
}
}
/**
* Behavioral SlotCore fake (the real ui-slots is still a T0 stub): registration
* mutates entries, bumps the key version, and notifies subscribers synchronously
* (batching semantics belong to fw-slots' core, not this package's outlet).
* Behavioral SlotRendererHost fake: registration mutates entries, bumps the
* key version, and notifies synchronously (batching semantics belong to the
* runtime host, not this package's outlets). Store instances resolve through
* the entry's real handle, cached per (entry x scope key) like the real
* ledger; session cells are identity-stable per id.
*/
function makeFakeCore() {
const specs = new Map<string, SlotSpec<SlotEntryDef>>()
const entries = new Map<string, SlotEntry<SlotEntryDef>[]>()
function makeHost() {
const entries = new Map<string, StoredEntry[]>()
const specs = new Map<string, DeclaredSpec>()
const versions = new Map<string, number>()
const subs = new Map<string, Set<() => void>>()
const live = new Set<StoredEntry>()
const storeCache = new Map<StoredEntry, Map<string, StoreInstanceLike>>()
const list = observable<{ ids: string[] }>({ ids: [] })
const current = observable<string | undefined>(undefined)
const cells = new Map<string, SessionCell>()
const bump = (key: string) => {
versions.set(key, (versions.get(key) ?? 0) + 1)
for (const fn of [...(subs.get(key) ?? [])]) fn()
}
const core = {
define: (key: string, spec: SlotSpec<SlotEntryDef>) => {
specs.set(key, spec)
bump(key)
return () => { specs.delete(key); bump(key) }
},
// Options widened beyond SlotOptions<SlotEntryDef>: fake keys ('fake.list')
// are not in SlotMap, so calls resolve against this signature and need the
// list/keyed fields the conditional type would otherwise narrow away.
register: (
key: string, component: FC<object>,
options: { key?: string; id?: string; order?: number; label?: string; inject?: InjectFactory<SlotEntryDef> } = {},
) => {
const list = entries.get(key) ?? []
const entry: SlotEntry<SlotEntryDef> = { component, options }
entries.set(key, [...list, entry])
bump(key)
return () => {
entries.set(key, (entries.get(key) ?? []).filter((e) => e !== entry))
bump(key)
}
},
entries: (key: string) => entries.get(key) ?? [],
spec: (key: string) => specs.get(key),
subscribe: (key: string, fn: () => void) => {
const host: SlotRendererHost = {
subscribe: (key, fn) => {
const set = subs.get(key) ?? new Set()
set.add(fn)
subs.set(key, set)
return () => { set.delete(fn) }
},
getVersion: (key: string) => versions.get(key) ?? 0,
onMutate: () => () => {},
getVersion: (key) => versions.get(key) ?? 0,
entriesOf: (key) => entries.get(key) ?? [],
specOf: (key) => specs.get(key),
isLive: (entry) => live.has(entry),
storeOf: (entry, scopeKey) => {
if (entry.store === undefined) return undefined
let perScope = storeCache.get(entry)
if (!perScope) {
perScope = new Map()
storeCache.set(entry, perScope)
}
const cacheKey = scopeKey ?? ''
let instance = perScope.get(cacheKey)
if (!instance) {
// Fake entries always carry engine handles (never factories), and the
// engine create() takes the scope key (persist suffixing).
const handle = entry.store as { create(scopeKey?: string): StoreInstanceLike }
instance = handle.create(scopeKey)
perScope.set(cacheKey, instance)
}
return instance
},
sessions: {
list,
current,
cell: (id) => cells.get(id),
},
}
return {
host,
list,
current,
declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) },
add: (key: string, partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }) => {
const entry = entryOf(partial)
entries.set(key, [...(entries.get(key) ?? []), entry])
live.add(entry)
bump(key)
return () => {
entries.set(key, (entries.get(key) ?? []).filter((e) => e !== entry))
live.delete(entry)
bump(key)
}
},
addSession: (id: string): SessionCell => {
const cell: SessionCell = { sessionId: id, useSession: { hookTag: id } }
cells.set(id, cell)
return cell
},
}
return core as unknown as SlotCore & typeof core
}
const useSelectorStub = (() => { throw new Error('unused in these specs') }) as never
type Fake = ReturnType<typeof makeHost>
const makeBinding = (sessionId: string): SessionBinding => ({
sessionId, session: { useSelector: useSelectorStub }, ctx: { tag: sessionId },
})
/** Mount ui under a SessionProvider bound to one switchable session. */
function sessionHarness(body: (id: string) => React.ReactNode, bindings: Record<string, SessionBinding>) {
const current = createSnapshotStore<{ id: string | undefined }>({ id: undefined })
const deps: SessionProviderDeps = {
useCurrent: () => current.useSelector((s) => s.id),
resolveBinding: (id) => bindings[id],
renderBody: body,
}
const Provider = createSessionProvider(deps)
return { current, Provider }
/** Mount a root entry whose component renders `body` with its kit renderSlot. */
function mountRoot(h: Fake, children: Record<string, DeclaredSpec>, body: (renderSlot: RenderSlotFn) => ReactNode) {
const dispose = h.add('root', {
component: (props: { renderSlot: RenderSlotFn }) => <>{body(props.renderSlot)}</>,
children,
})
const renderer = createSlotRenderer()
const view = render(<>{renderer.renderRoot(h.host, {})}</>)
return { view, dispose }
}
describe('scopedSlots basics', () => {
it('throws on renderSlot before define and on non-whitelisted keys', () => {
const core = makeFakeCore()
const slots = scopedSlots(core, 'fake.root' as never)
expect(() => slots.renderSlot('fake.session' as never, {})).toThrow(/whitelist/)
const SINGLE_ROOT: DeclaredSpec = { kind: 'single', scope: 'root' }
const SINGLE_SESSION: DeclaredSpec = { kind: 'single', scope: 'session' }
describe('root outlet', () => {
it('renders the root registration and fails loud when root is unregistered (boot order)', () => {
const h = makeHost()
h.add('root', { component: () => <b>shell</b> })
const renderer = createSlotRenderer()
const view = render(<>{renderer.renderRoot(h.host, {})}</>)
expect(view.container.textContent).toBe('shell')
const empty = makeHost()
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render(<>{slots.renderSlot('fake.root' as never, {})}</>)).toThrow(/before define/)
expect(() => render(<>{createSlotRenderer().renderRoot(empty.host, {})}</>))
.toThrow(/boot order/)
spy.mockRestore()
})
it('renders single-kind root slots, falls back when empty, live-updates on register/dispose', () => {
const core = makeFakeCore()
core.define('fake.root', { kind: 'single', scope: 'root' })
const slots = scopedSlots(core, 'fake.root' as never)
const view = render(<>{slots.renderSlot('fake.root' as never, {}, { fallback: <i>none</i> })}</>)
it('passes renderRoot owner props into the root component', () => {
const h = makeHost()
h.add('root', { component: ({ tag }: { tag?: string }) => <b>{tag}</b> })
const view = render(<>{createSlotRenderer().renderRoot(h.host, { tag: 'OWNER' })}</>)
expect(view.container.textContent).toBe('OWNER')
})
})
describe('child outlets and the renderSlot binding', () => {
it('renders declared single slots live: fallback when empty, register, dispose back', () => {
const h = makeHost()
h.declare('k.single', SINGLE_ROOT)
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT },
(renderSlot) => renderSlot('k.single', {}, { fallback: <i>none</i> }))
expect(view.container.textContent).toBe('none')
let dispose = () => {}
act(() => { dispose = core.register('fake.root', () => <b>SB</b>) })
act(() => { dispose = h.add('k.single', { component: () => <b>SB</b> }) })
expect(view.container.textContent).toBe('SB')
act(() => { dispose() })
expect(view.container.textContent).toBe('none')
})
it('renders list slots in order, honors only-filter, keyed slots dispatch by entryKey', () => {
const core = makeFakeCore()
core.define('fake.list', { kind: 'list', scope: 'root' })
core.define('fake.keyed', { kind: 'keyed', scope: 'root' })
core.register('fake.list', () => <span>b</span>, { id: 'b', order: 2 })
core.register('fake.list', () => <span>a</span>, { id: 'a', order: 1 })
core.register('fake.keyed', () => <span>goal</span>, { key: 'goal' })
const slots = scopedSlots(core, 'fake.list' as never, 'fake.keyed' as never)
const list = render(<>{slots.renderSlot('fake.list' as never, {})}</>)
expect(list.container.textContent).toBe('ab')
const only = render(<>{slots.renderSlot('fake.list' as never, {}, { only: 'b' })}</>)
expect(only.container.textContent).toBe('b')
const hit = render(<>{slots.renderSlot('fake.keyed' as never, {}, { entryKey: 'goal' })}</>)
expect(hit.container.textContent).toBe('goal')
const miss = render(
<>{slots.renderSlot('fake.keyed' as never, {}, { entryKey: 'nope', fallback: <i>fb</i> })}</>)
expect(miss.container.textContent).toBe('fb')
it('renders an undeclared key as empty (declaring entry unloaded = natural blank, not a crash)', () => {
const h = makeHost()
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT },
(renderSlot) => <main>{renderSlot('k.single', {}, { fallback: <i>fb</i> })}</main>)
// Declared by children (authorization) but absent from the ledger (specOf
// undefined): the outlet renders nothing, not even the fallback path's spec dispatch.
expect(view.container.querySelector('main')!.textContent).toBe('')
})
it('contains a throwing root inject factory to its own entry (P1 whiteout regression)', () => {
const core = makeFakeCore()
core.define('fake.list', { kind: 'list', scope: 'root' })
core.register('fake.list', () => <span>never</span>, {
id: 'bad', order: 1,
inject: (() => { throw new Error('inject boom') }) as unknown as InjectFactory<SlotEntryDef>,
it('orders list entries, honors only-filter, dispatches keyed entries by entryKey', () => {
const h = makeHost()
h.declare('k.list', { kind: 'list', scope: 'root' })
h.declare('k.keyed', { kind: 'keyed', scope: 'root' })
h.add('k.list', { component: () => <span>b</span>, options: { id: 'b', order: 2 } })
h.add('k.list', { component: () => <span>a</span>, options: { id: 'a', order: 1 } })
h.add('k.keyed', { component: () => <span>goal</span>, options: { key: 'goal' } })
const children = { 'k.list': { kind: 'list', scope: 'root' } as DeclaredSpec, 'k.keyed': { kind: 'keyed', scope: 'root' } as DeclaredSpec }
const { view } = mountRoot(h, children, (renderSlot) => <>
<main>{renderSlot('k.list', {})}</main>
<aside>{renderSlot('k.list', {}, { only: 'b' })}</aside>
<nav>{renderSlot('k.keyed', {}, { entryKey: 'goal' })}</nav>
<footer>{renderSlot('k.keyed', {}, { entryKey: 'nope', fallback: <i>fb</i> })}</footer>
</>)
expect(view.container.querySelector('main')!.textContent).toBe('ab')
expect(view.container.querySelector('aside')!.textContent).toBe('b')
expect(view.container.querySelector('nav')!.textContent).toBe('goal')
expect(view.container.querySelector('footer')!.textContent).toBe('fb')
})
it('keeps the binding identity-stable across re-renders and throws SlotOwnershipError off-declaration', () => {
const h = makeHost()
h.declare('k.single', SINGLE_ROOT)
const seen: RenderSlotFn[] = []
mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => {
seen.push(renderSlot)
return renderSlot('k.single', {})
})
core.register('fake.list', () => <span>alive</span>, { id: 'ok', order: 2 })
const slots = scopedSlots(core, 'fake.list' as never)
const root: RootBinding = { ctx: {} }
// Bump the 'root' key to force a root-entry re-render (the single-kind
// outlet only reads entries[0], so the extra entry is inert).
act(() => { h.add('root', { component: () => null }) })
expect(seen.length).toBeGreaterThan(1)
expect(seen.at(-1)).toBe(seen[0])
expect(() => seen[0]!('k.undeclared', {})).toThrow(SlotOwnershipError)
})
it('isolates a crashing entry without collapsing siblings', () => {
const h = makeHost()
h.declare('k.list', { kind: 'list', scope: 'root' })
h.add('k.list', { component: () => { throw new Error('entry boom') }, options: { id: 'bad', order: 1 } })
h.add('k.list', { component: () => <span>alive</span>, options: { id: 'ok', order: 2 } })
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const view = render(
<RootBindingProvider value={root}>
<main>{slots.renderSlot('fake.list' as never, {})}</main>
</RootBindingProvider>,
)
const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } },
(renderSlot) => renderSlot('k.list', {}))
spy.mockRestore()
expect(view.container.textContent).toBe('alive')
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
})
})
describe('standard-kit synthesis', () => {
it('delivers a live useSessions hook to every slot component', () => {
const h = makeHost()
h.declare('k.single', SINGLE_ROOT)
h.add('k.single', {
component: ({ useSessions }: { useSessions: <S>(sel: (s: { ids: string[] }) => S) => S }) =>
<b>{useSessions((s) => s.ids.length)}</b>,
})
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {}))
expect(view.container.textContent).toBe('0')
act(() => { h.list.set({ ids: ['a', 'b'] }) })
expect(view.container.textContent).toBe('2')
})
it('delivers the session pair (useSession identity + sessionId) under SessionProvider', () => {
const h = makeHost()
h.declare('k.session', SINGLE_SESSION)
const cell = h.addSession('s1')
const seen: AnyProps[] = []
h.add('k.session', { component: (props: object) => { seen.push(props as AnyProps); return null } })
mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => (
<SessionProvider empty={() => <i>empty</i>}>
{() => renderSlot('k.session', {})}
</SessionProvider>
))
act(() => { h.current.set('s1') })
const props = seen.at(-1)!
expect(props['useSession']).toBe(cell.useSession)
expect(props['sessionId']).toBe('s1')
})
it('fails loud when a session slot renders outside SessionProvider', () => {
const h = makeHost()
h.declare('k.session', SINGLE_SESSION)
h.add('k.session', { component: () => <b>x</b> })
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => mountRoot(h, { 'k.session': SINGLE_SESSION },
(renderSlot) => renderSlot('k.session', {}))).toThrow(/outside SessionProvider/)
spy.mockRestore()
})
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 } },
})
let bump = () => {}
h.add('k.single', {
component: ({ useStore, actions }: {
useStore: <S>(sel: (s: { n: number }) => S) => S
actions: { inc: () => void }
}) => {
bump = actions.inc
return <b>{useStore((s) => s.n)}</b>
},
store: handle,
})
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {}))
expect(view.container.textContent).toBe('0')
act(() => { bump() })
expect(view.container.textContent).toBe('1')
})
it('resolves session-slot stores per scope key: values survive a switch-away and back', () => {
const h = makeHost()
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 } },
})
let setDraft: (text: string) => void = () => {}
h.add('k.session', {
component: ({ useStore, actions }: {
useStore: <S>(sel: (s: { draft: string }) => S) => S
actions: { setDraft: (text: string) => void }
}) => {
setDraft = actions.setDraft
return <b>{useStore((s) => s.draft) || '(blank)'}</b>
},
store: handle,
})
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => (
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
))
act(() => { h.current.set('s1') })
act(() => { setDraft('draft-one') })
expect(view.container.textContent).toBe('draft-one')
act(() => { h.current.set('s2') })
expect(view.container.textContent).toBe('(blank)') // distinct instance per session
act(() => { h.current.set('s1') })
expect(view.container.textContent).toBe('draft-one') // same scope key = same instance
})
})
describe('inject: execution point, parameter derivation, cache granularity', () => {
it('root inject runs once per entry with no arguments (no store declared)', () => {
const h = makeHost()
h.declare('k.single', SINGLE_ROOT)
const inject = vi.fn(() => ({ tag: 'FROM-INJECT' }))
h.add('k.single', { component: ({ tag }: { tag?: string }) => <b>{tag}</b>, inject })
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {}))
expect(view.container.textContent).toBe('FROM-INJECT')
act(() => { h.add('k.single', { component: () => null }) }) // sibling bump re-renders the outlet
expect(inject).toHaveBeenCalledTimes(1)
expect(inject).toHaveBeenCalledWith()
})
it('session inject receives sessionId and caches per (entry x session): switch-back reuses', () => {
const h = makeHost()
h.declare('k.session', SINGLE_SESSION)
h.addSession('s1')
h.addSession('s2')
const inject = vi.fn((sessionId: string) => ({ sid: sessionId }))
h.add('k.session', {
component: ({ sid }: { sid?: string }) => <b>{sid}</b>,
inject: inject as unknown as StoredEntry['inject'],
})
const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => (
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
))
act(() => { h.current.set('s1') })
expect(view.container.textContent).toBe('s1')
expect(inject).toHaveBeenCalledTimes(1)
expect(inject).toHaveBeenLastCalledWith('s1')
act(() => { h.current.set('s2') })
expect(view.container.textContent).toBe('s2')
expect(inject).toHaveBeenCalledTimes(2)
act(() => { h.current.set('s1') }) // back: (entry x cell) cache hit
expect(view.container.textContent).toBe('s1')
expect(inject).toHaveBeenCalledTimes(2)
})
it('store-declaring entries get baked actions appended to the inject parameters', () => {
const h = makeHost()
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 rootInject = vi.fn((actions: { inc: () => void }) => ({ viaRoot: actions }))
const sessionInject = vi.fn((sessionId: string, actions: { inc: () => void }) => ({ sid: sessionId, viaSession: actions }))
const seenRoot: AnyProps[] = []
const seenSession: AnyProps[] = []
h.add('k.single', {
component: (props: object) => { seenRoot.push(props as AnyProps); return null },
inject: rootInject as unknown as StoredEntry['inject'],
store: handle,
})
h.add('k.session', {
component: (props: object) => { seenSession.push(props as AnyProps); return null },
inject: sessionInject as unknown as StoredEntry['inject'],
store: handle,
})
mountRoot(h, { 'k.single': SINGLE_ROOT, 'k.session': SINGLE_SESSION }, (renderSlot) => <>
{renderSlot('k.single', {})}
<SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
</>)
act(() => { h.current.set('s1') })
// The inject-received actions are the same baked callbacks the component
// gets as props.actions (one instance per entry x scope key).
expect(rootInject).toHaveBeenCalledTimes(1)
expect(seenRoot.at(-1)!['viaRoot']).toBe(seenRoot.at(-1)!['actions'])
expect(sessionInject).toHaveBeenCalledTimes(1)
expect(sessionInject.mock.calls[0]![0]).toBe('s1')
expect(seenSession.at(-1)!['viaSession']).toBe(seenSession.at(-1)!['actions'])
})
it('contains a throwing inject factory to its own entry (runs inside the component body)', () => {
const h = makeHost()
h.declare('k.list', { kind: 'list', scope: 'root' })
h.add('k.list', {
component: () => <span>never</span>,
options: { id: 'bad', order: 1 },
inject: () => { throw new Error('inject boom') },
})
h.add('k.list', { component: () => <span>alive</span>, options: { id: 'ok', order: 2 } })
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } },
(renderSlot) => <main>{renderSlot('k.list', {})}</main>)
spy.mockRestore()
// The failing entry blacks out alone; the sibling and the tree above survive.
expect(view.container.querySelector('main')).not.toBeNull()
@@ -144,131 +417,20 @@ describe('scopedSlots basics', () => {
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
})
it('contains a throwing session inject factory to its own entry', () => {
const core = makeFakeCore()
core.define('fake.session', { kind: 'single', scope: 'session' })
core.register('fake.session', () => <span>never</span>, {
inject: (() => { throw new Error('session inject boom') }) as unknown as InjectFactory<SlotEntryDef>,
it('merges kit, inject, and owner props with owner winning', () => {
const h = makeHost()
h.declare('k.single', SINGLE_ROOT)
const seen: AnyProps[] = []
h.add('k.single', {
component: (props: object) => { seen.push(props as AnyProps); return null },
inject: () => ({ fromInject: 'inject', shared: 'inject' }),
})
const slots = scopedSlots(core, 'fake.session' as never)
const bindings = { s1: makeBinding('s1') }
const { current, Provider } = sessionHarness(
(id) => <main data-shell={id}>{slots.renderSlot('fake.session' as never, {})}</main>, bindings)
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const view = render(<Provider />)
act(() => { current.update((d) => { d.id = 's1' }) })
spy.mockRestore()
expect(view.container.querySelector('[data-shell]')).not.toBeNull()
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
})
it('isolates a crashing entry without collapsing siblings', () => {
const core = makeFakeCore()
core.define('fake.list', { kind: 'list', scope: 'root' })
core.register('fake.list', () => { throw new Error('entry boom') }, { id: 'bad', order: 1 })
core.register('fake.list', () => <span>alive</span>, { id: 'ok', order: 2 })
const slots = scopedSlots(core, 'fake.list' as never)
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const view = render(<>{slots.renderSlot('fake.list' as never, {})}</>)
spy.mockRestore()
expect(view.container.textContent).toBe('alive')
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
})
})
describe('inject caching and props merge', () => {
it('root inject runs once per entry and receives the root binding ctx', () => {
const core = makeFakeCore()
core.define('fake.root', { kind: 'single', scope: 'root' })
const inject = vi.fn((b: RootBinding) => ({ tag: (b.ctx as { tag: string }).tag }))
core.register('fake.root', ({ tag }: { tag?: string }) => <b>{tag}</b>,
{ inject: inject as unknown as InjectFactory<SlotEntryDef> })
const slots = scopedSlots(core, 'fake.root' as never)
const root: RootBinding = { ctx: { tag: 'ROOT' } }
const view = render(
<RootBindingProvider value={root}>
{slots.renderSlot('fake.root' as never, {})}
</RootBindingProvider>,
)
expect(view.container.textContent).toBe('ROOT')
view.rerender(
<RootBindingProvider value={root}>
{slots.renderSlot('fake.root' as never, {})}
</RootBindingProvider>,
)
expect(inject).toHaveBeenCalledTimes(1)
})
it('root slots with inject throw without RootBindingProvider; plain entries do not need it', () => {
const core = makeFakeCore()
core.define('fake.root', { kind: 'single', scope: 'root' })
core.register('fake.root', () => <b>plain</b>)
const slots = scopedSlots(core, 'fake.root' as never)
const view = render(<>{slots.renderSlot('fake.root' as never, {})}</>)
expect(view.container.textContent).toBe('plain')
const core2 = makeFakeCore()
core2.define('fake.root', { kind: 'single', scope: 'root' })
core2.register('fake.root', () => <b>x</b>,
{ inject: (() => ({})) as unknown as InjectFactory<SlotEntryDef> })
const slots2 = scopedSlots(core2, 'fake.root' as never)
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render(<>{slots2.renderSlot('fake.root' as never, {})}</>))
.toThrow(/RootBindingProvider/)
spy.mockRestore()
})
it('session inject caches per (entry x binding): session switch re-invokes, switch-back reuses', () => {
const core = makeFakeCore()
core.define('fake.session', { kind: 'single', scope: 'session' })
const inject = vi.fn((b: { sessionId: string }) => ({ sid: b.sessionId }))
core.register('fake.session', ({ sid }: { sid?: string }) => <b>{sid}</b>,
{ inject: inject as unknown as InjectFactory<SlotEntryDef> })
const slots = scopedSlots(core, 'fake.session' as never)
const bindings = { s1: makeBinding('s1'), s2: makeBinding('s2') }
const { current, Provider } = sessionHarness(
() => slots.renderSlot('fake.session' as never, {}), bindings)
const view = render(<Provider />)
act(() => { current.update((d) => { d.id = 's1' }) })
expect(view.container.textContent).toBe('s1')
expect(inject).toHaveBeenCalledTimes(1)
act(() => { current.update((d) => { d.id = 's2' }) })
expect(view.container.textContent).toBe('s2')
expect(inject).toHaveBeenCalledTimes(2)
act(() => { current.update((d) => { d.id = 's1' }) }) // back: cache hit
expect(view.container.textContent).toBe('s1')
expect(inject).toHaveBeenCalledTimes(2)
})
it('session slots receive standard useSession injection and owner props win the merge', () => {
const core = makeFakeCore()
core.define('fake.session', { kind: 'single', scope: 'session' })
const seen: Record<string, unknown>[] = []
core.register('fake.session', (props: object) => {
seen.push(props as Record<string, unknown>)
return null
}, { inject: (() => ({ fromInject: 'inject', shared: 'inject' })) as unknown as InjectFactory<SlotEntryDef> })
const slots = scopedSlots(core, 'fake.session' as never)
const bindings = { s1: makeBinding('s1') }
const { current, Provider } = sessionHarness(
() => slots.renderSlot('fake.session' as never, { owner: 'owner', shared: 'owner' } as never), bindings)
render(<Provider />)
act(() => { current.update((d) => { d.id = 's1' }) })
mountRoot(h, { 'k.single': SINGLE_ROOT },
(renderSlot) => renderSlot('k.single', { owner: 'owner', shared: 'owner' }))
const props = seen.at(-1)!
expect(props.useSession).toBe(bindings.s1.session.useSelector)
expect(props.fromInject).toBe('inject')
expect(props.owner).toBe('owner')
expect(props.shared).toBe('owner') // three-source merge: owner overrides inject
})
it('session slots outside a SessionProvider fail loud', () => {
const core = makeFakeCore()
core.define('fake.session', { kind: 'single', scope: 'session' })
core.register('fake.session', () => <b>x</b>)
const slots = scopedSlots(core, 'fake.session' as never)
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render(<>{slots.renderSlot('fake.session' as never, {})}</>))
.toThrow(/outside SessionProvider/)
spy.mockRestore()
expect(typeof props['useSessions']).toBe('function') // kit always present
expect(props['fromInject']).toBe('inject')
expect(props['owner']).toBe('owner')
expect(props['shared']).toBe('owner') // owner overrides inject
})
})
@@ -1,106 +1,143 @@
// @vitest-environment jsdom
/**
* SessionProvider behavior account (render-prop form, framework-wired):
* empty/body branching off the host's current-session source, key={sessionId}
* remount semantics, and cell delivery observed through a session slot's
* standard kit — never through the internal context objects (BindingContext
* does not leave the package).
*/
import { useEffect, useRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import type { RootBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import {
createSessionProvider, createSnapshotStore, RootBindingProvider,
useRootBinding, useSessionBinding,
type SessionBinding, type SessionProviderDeps,
createSlotRenderer, SessionProvider,
type SessionCell, type SlotRendererHost,
} from '@deepseek-ai/dsh-client-web-react'
const makeBinding = (sessionId: string): SessionBinding => ({
sessionId,
session: { useSelector: (() => { throw new Error('unused') }) as never },
ctx: { tag: sessionId },
})
function setup(bindings: Record<string, SessionBinding>) {
const current = createSnapshotStore<{ id: string | undefined }>({ id: undefined })
const resolveBinding = vi.fn((id: string) => bindings[id])
const seen: { id: string; binding: SessionBinding; mountCount: number }[] = []
let mounts = 0
function Body({ id }: { id: string }) {
const binding = useSessionBinding()
const mountRef = useRef(0)
useEffect(() => { mounts += 1; mountRef.current = mounts }, [])
seen.push({ id, binding, mountCount: mountRef.current })
return <div data-testid="body">{id}</div>
function observable<T>(initial: T) {
let value = initial
const subs = new Set<() => void>()
return {
getSnapshot: () => value,
subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } },
set: (next: T) => { value = next; for (const fn of [...subs]) fn() },
}
const deps: SessionProviderDeps = {
useCurrent: () => current.useSelector((s) => s.id),
resolveBinding,
renderBody: (id) => <Body id={id} />,
}
const SessionProvider = createSessionProvider(deps)
return { current, resolveBinding, SessionProvider, seen, mountCount: () => mounts }
}
describe('createSessionProvider', () => {
it('renders empty without a current session and switches to the body on select', () => {
const { current, SessionProvider } = setup({ s1: makeBinding('s1') })
const view = render(<SessionProvider renderEmpty={() => <span>empty</span>} />)
/**
* Minimal host: SessionProvider only reads sessions.current/cell, but it must
* render inside the renderer tree (HostContext), so the harness mounts a real
* root entry whose body is the test's render-prop provider.
*/
function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.ReactNode) => React.ReactNode }) {
const current = observable<string | undefined>(undefined)
const cells = new Map<string, SessionCell>()
const sessionEntries: StoredEntry[] = []
const rootEntry: StoredEntry = {
component: (props: { renderSlot: (key: string, owner: object) => React.ReactNode }) =>
<>{bodies.root(props.renderSlot)}</>,
options: {},
children: { 'k.session': { kind: 'single', scope: 'session' } },
}
const host: SlotRendererHost = {
subscribe: () => () => {},
getVersion: () => 0,
entriesOf: (key) => key === 'root' ? [rootEntry] : sessionEntries,
specOf: (key) => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
isLive: () => true,
storeOf: () => undefined,
sessions: {
list: observable<unknown>({ ids: [] }),
current,
cell: (id) => cells.get(id),
},
}
return {
host,
current,
addSession: (id: string) => {
const cell: SessionCell = { sessionId: id, useSession: { hookTag: id } }
cells.set(id, cell)
return cell
},
registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
}
}
describe('SessionProvider', () => {
it('renders empty without a current session, switches to the body on select, falls back on an unresolvable id', () => {
const h = makeHost({
root: () => (
<SessionProvider empty={() => <span>empty</span>}>
{(id) => <div data-testid="body">{id}</div>}
</SessionProvider>
),
})
h.addSession('s1')
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(view.container.textContent).toBe('empty')
act(() => { current.update((d) => { d.id = 's1' }) })
act(() => { h.current.set('s1') })
expect(view.container.textContent).toBe('s1')
act(() => { h.current.set('ghost') }) // listed nowhere: cell() misses
expect(view.container.textContent).toBe('empty')
})
it('renders null empty state when renderEmpty is omitted', () => {
const { SessionProvider } = setup({})
const view = render(<SessionProvider />)
it('renders null empty state when the empty prop is omitted', () => {
const h = makeHost({
root: () => <SessionProvider>{(id) => <b>{id}</b>}</SessionProvider>,
})
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(view.container.textContent).toBe('')
})
it('falls back to empty when the binding does not resolve', () => {
const { current, SessionProvider } = setup({})
const view = render(<SessionProvider renderEmpty={() => <span>empty</span>} />)
act(() => { current.update((d) => { d.id = 'ghost' }) })
expect(view.container.textContent).toBe('empty')
it('remounts the body on session switch (key semantics) but not on unrelated re-renders', () => {
let mounts = 0
function Body({ id }: { id: string }) {
const mounted = useRef(false)
useEffect(() => {
/* v8 ignore next -- strict-mode double-invoke guard, not a branch under test */
if (!mounted.current) { mounted.current = true; mounts += 1 }
}, [])
return <div>{id}</div>
}
const h = makeHost({
root: () => <SessionProvider>{(id) => <Body id={id} />}</SessionProvider>,
})
h.addSession('s1')
h.addSession('s2')
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
act(() => { h.current.set('s1') })
const afterS1 = mounts
act(() => { h.current.set('s2') })
expect(mounts).toBe(afterS1 + 1)
const afterS2 = mounts
view.rerender(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(mounts).toBe(afterS2)
})
it('passes the resolved binding through context and remounts on session switch', () => {
const bindings = { s1: makeBinding('s1'), s2: makeBinding('s2') }
const { current, SessionProvider, seen, mountCount } = setup(bindings)
render(<SessionProvider />)
act(() => { current.update((d) => { d.id = 's1' }) })
expect(seen.at(-1)!.binding).toBe(bindings.s1)
const mountsAfterS1 = mountCount()
act(() => { current.update((d) => { d.id = 's2' }) })
expect(seen.at(-1)!.binding).toBe(bindings.s2)
// key={id} semantics: switching sessions remounts the body subtree.
expect(mountCount()).toBe(mountsAfterS1 + 1)
it('delivers the resolved cell to session slots under it (observable behavior, not context internals)', () => {
const seen: Record<string, unknown>[] = []
const h = makeHost({
root: (renderSlot) => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
})
const s1 = h.addSession('s1')
const s2 = h.addSession('s2')
h.registerSession({ component: (props: object) => { seen.push(props as Record<string, unknown>); return null }, options: {} })
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
act(() => { h.current.set('s1') })
expect(seen.at(-1)!['useSession']).toBe(s1.useSession)
expect(seen.at(-1)!['sessionId']).toBe('s1')
act(() => { h.current.set('s2') })
expect(seen.at(-1)!['useSession']).toBe(s2.useSession)
expect(seen.at(-1)!['sessionId']).toBe('s2')
})
it('does not remount the body when unrelated renders happen on the same session', () => {
const bindings = { s1: makeBinding('s1') }
const { current, SessionProvider, mountCount } = setup(bindings)
const view = render(<SessionProvider />)
act(() => { current.update((d) => { d.id = 's1' }) })
const mounts = mountCount()
view.rerender(<SessionProvider />)
expect(mountCount()).toBe(mounts)
})
})
describe('binding contexts', () => {
it('useSessionBinding throws outside a SessionProvider subtree', () => {
it('fails loud when mounted outside the renderer tree (no host channel)', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
function Naked() { useSessionBinding(); return null }
expect(() => render(<Naked />)).toThrow(/outside SessionProvider/)
spy.mockRestore()
})
it('RootBindingProvider supplies the root binding; absence throws', () => {
const root: RootBinding = { ctx: { tag: 'root' } }
let got: RootBinding | undefined
function Probe() { got = useRootBinding(); return null }
render(<RootBindingProvider value={root}><Probe /></RootBindingProvider>)
expect(got).toBe(root)
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render(<Probe />)).toThrow(/RootBindingProvider/)
expect(() => render(
<SessionProvider>{(id) => <b>{id}</b>}</SessionProvider>,
)).toThrow(/outside the installed renderer tree/)
spy.mockRestore()
})
})
@@ -0,0 +1,136 @@
// @vitest-environment jsdom
/**
* Stale renderSlot bindings (slot terminal design §9): a binding dies with
* its entry — a retained closure invoked after the entry's disposal throws
* StaleAuthorizationError off the ledger check, and an HMR-style reload (new
* entry, same key) mints a NEW binding rather than reviving the old one.
*/
import { describe, expect, it } 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 {
createSlotRenderer, StaleAuthorizationError,
type RenderOpts, type SlotRendererHost,
} from '@deepseek-ai/dsh-client-web-react'
type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode
type DeclaredSpec = SlotSpec<SlotEntryDef>
/** Ledger-shaped fake: add/dispose maintain the live set the way the runtime ledger does. */
function makeHost() {
const entries = new Map<string, StoredEntry[]>()
const versions = new Map<string, number>()
const subs = new Map<string, Set<() => void>>()
const live = new Set<StoredEntry>()
const bump = (key: string) => {
versions.set(key, (versions.get(key) ?? 0) + 1)
for (const fn of [...(subs.get(key) ?? [])]) fn()
}
const host: SlotRendererHost = {
subscribe: (key, fn) => {
const set = subs.get(key) ?? new Set()
set.add(fn)
subs.set(key, set)
return () => { set.delete(fn) }
},
getVersion: (key) => versions.get(key) ?? 0,
entriesOf: (key) => entries.get(key) ?? [],
specOf: () => ({ kind: 'single', scope: 'root' }),
isLive: (entry) => live.has(entry),
storeOf: () => undefined,
sessions: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
current: { getSnapshot: () => undefined, subscribe: () => () => {} },
cell: () => undefined,
},
}
return {
host,
add: (key: string, entry: StoredEntry) => {
entries.set(key, [...(entries.get(key) ?? []), entry])
live.add(entry)
bump(key)
return () => {
entries.set(key, (entries.get(key) ?? []).filter((e) => e !== entry))
live.delete(entry)
bump(key)
}
},
}
}
const CHILD: DeclaredSpec = { kind: 'single', scope: 'root' }
/**
* Mount a root entry that leaks its binding to the test, then render. The
* returned dispose unmounts the view FIRST: an empty 'root' makes the live
* root outlet rethrow its boot-order failure (fail-loud, covered in the
* scoped-slots suite); the retained-closure scenario under test here is a
* dead entry whose binding outlives the tree.
*/
function mountCapturing(h: ReturnType<typeof makeHost>) {
let captured: RenderSlotFn | undefined
const entry: StoredEntry = {
component: (props: { renderSlot: RenderSlotFn }) => {
captured = props.renderSlot
return null
},
options: {},
children: { 'k.child': CHILD },
}
const disposeEntry = h.add('root', entry)
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
return {
binding: captured!,
entry,
dispose: () => {
view.unmount()
disposeEntry()
},
}
}
describe('stale authorization', () => {
it('a live binding renders; the same closure throws after its entry is disposed', () => {
const h = makeHost()
const { binding, dispose } = mountCapturing(h)
expect(binding('k.child', {})).not.toBeUndefined() // live: returns an element
act(() => { dispose() })
expect(() => binding('k.child', {})).toThrow(StaleAuthorizationError)
expect(() => binding('k.child', {})).toThrow(/disposed registration/)
})
it('stale check precedes the ownership check: a dead binding throws stale even for undeclared keys', () => {
const h = makeHost()
const { binding, dispose } = mountCapturing(h)
act(() => { dispose() })
// Were ownership checked first this would be SlotOwnershipError; the dead
// entry must fail on liveness regardless of the key asked for.
expect(() => binding('k.undeclared', {})).toThrow(StaleAuthorizationError)
})
it('HMR reload (same key, new entry) mints a fresh binding; the old one stays dead', () => {
const h = makeHost()
const first = mountCapturing(h)
act(() => { first.dispose() })
// Reload: a new entry object for the same slot key (new registration identity).
let secondBinding: RenderSlotFn | undefined
const secondEntry: StoredEntry = {
component: (props: { renderSlot: RenderSlotFn }) => {
secondBinding = props.renderSlot
return null
},
options: {},
children: { 'k.child': CHILD },
}
h.add('root', secondEntry)
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(secondBinding).toBeDefined()
expect(secondBinding).not.toBe(first.binding) // new identity, no revival
expect(secondBinding!('k.child', {})).not.toBeUndefined() // new binding is live
expect(() => first.binding('k.child', {})).toThrow(StaleAuthorizationError) // old stays dead
})
})
+93 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createSnapshotStore, shallowEqual } from '@deepseek-ai/dsh-client-web-react/store'
import { createSnapshotStore, defineStore, shallowEqual } from '@deepseek-ai/dsh-client-web-react/store'
interface State {
a: { n: number }
@@ -124,6 +124,98 @@ describe('createSnapshotStore', () => {
})
})
describe('defineStore', () => {
const declare = () => defineStore({
init: () => ({ selection: null as string | null, draft: '' }),
actions: {
select: (d, target: string) => { d.selection = target },
setDraft: (d, text: string) => { d.draft = text },
clearDraft: (d) => { d.draft = '' },
},
})
it('create() yields a live instance: fresh init state, selector-visible action writes', () => {
const inst = declare().create()
expect(inst.store.getSnapshot()).toEqual({ selection: null, draft: '' })
inst.actions.setDraft('hello')
inst.actions.select('m1')
expect(inst.store.getSnapshot()).toEqual({ selection: 'm1', draft: 'hello' })
inst.actions.clearDraft()
expect(inst.store.getSnapshot().draft).toBe('')
})
it('bakes draft-stripped actions that write through update (draft mutation, not replacement)', () => {
const inst = declare().create()
const before = inst.store.getSnapshot()
inst.actions.setDraft('x')
const after = inst.store.getSnapshot()
expect(after).not.toBe(before)
expect(after.selection).toBe(before.selection) // untouched branch preserved (immer path)
})
it('creates independent instances per create() call (the handle is a spec, not a singleton)', () => {
const handle = declare()
const a = handle.create()
const b = handle.create()
a.actions.setDraft('only-a')
expect(b.store.getSnapshot().draft).toBe('')
})
it('suffixes the persist key with the scope key: per-session persistence plus clearPersisted cleanup', () => {
const backing = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (k: string) => backing.get(k) ?? null,
setItem: (k: string, v: string) => { backing.set(k, v) },
removeItem: (k: string) => { backing.delete(k) },
})
const handle = defineStore({
init: () => ({ draft: '' }),
persist: 'spec.chat',
actions: { setDraft: (d, text: string) => { d.draft = text } },
})
handle.create('s1').actions.setDraft('one')
handle.create('s2').actions.setDraft('two')
handle.create().actions.setDraft('root')
expect(JSON.parse(backing.get('spec.chat.s1')!)).toEqual({ draft: 'one' })
expect(JSON.parse(backing.get('spec.chat.s2')!)).toEqual({ draft: 'two' })
expect(JSON.parse(backing.get('spec.chat')!)).toEqual({ draft: 'root' })
// Rehydration honors the same suffixed key.
expect(handle.create('s1').store.getSnapshot().draft).toBe('one')
// Scope-death cleanup removes exactly the suffixed key.
handle.create('s1').clearPersisted()
expect(backing.has('spec.chat.s1')).toBe(false)
expect(backing.has('spec.chat.s2')).toBe(true)
expect(backing.has('spec.chat')).toBe(true)
})
it('clearPersisted is a no-op without a persist declaration or without storage', () => {
const inst = declare().create('s1') // no persist key declared
expect(() => { inst.clearPersisted() }).not.toThrow()
const persisting = defineStore({
init: () => ({ n: 0 }),
persist: 'spec.nostorage',
actions: { inc: (d) => { d.n += 1 } },
}).create()
// jsdom-less lane: localStorage may exist here, so simulate its absence.
vi.stubGlobal('localStorage', undefined)
expect(() => { persisting.clearPersisted() }).not.toThrow()
})
it('swallows storage failures in clearPersisted (same non-fatal contract as persistence)', () => {
vi.stubGlobal('localStorage', {
getItem: () => null,
setItem: () => {},
removeItem: () => { throw new Error('quota / private mode') },
})
const inst = defineStore({
init: () => ({ n: 0 }),
persist: 'spec.throwing',
actions: { inc: (d) => { d.n += 1 } },
}).create()
expect(() => { inst.clearPersisted() }).not.toThrow()
})
})
describe('shallowEqual', () => {
it('matches one-level-equal objects and rejects deeper drift', () => {
const leaf = { deep: 1 }
+8 -69
View File
@@ -1,24 +1,19 @@
/**
* Real-UI assembly closure. Runs only after loader.settled(): resolves the
* layout plugin's export surface from the loader module table (type-only
* import keeps the plugin out of the shell bundle), closes SessionProvider and
* scopedSlots over the shell's whitelist, and mounts RootBindingProvider so
* root-slot inject factories can reach ctx.
* Real-UI assembly closure. Runs only after loader.settled(): the whole
* layout tree hangs off the built-in 'root' slot (ui-layout registers
* AppFrame there and renders the child slots internally) — the shell's
* render is the one ctx-level renderSlot call in the program.
*/
import type { ReactNode } from 'react'
import type { Context } from 'cordis'
import {
createSessionProvider, RootBindingProvider, scopedSlots,
} from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
type LayoutExports = typeof import('@deepseek-ai/dsh-client-ui-layout/client')
// Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program.
import type {} from '@deepseek-ai/dsh-client-runtime/client'
/** Assembly inputs: the settled root ctx plus the loader's module-table read surface. */
export interface AssemblyDeps {
/** Client root context (all plugin services provided). */
ctx: Context
/** Module-table resolver (the loader's require; missing spec = throw). */
/** Module-table resolver (the loader's require; missing spec = throw). Kept in the seam for future shell needs. */
requireModule: (spec: string) => unknown
}
@@ -29,61 +24,5 @@ export interface AssemblyDeps {
*/
export function buildRenderApp(deps: AssemblyDeps): () => ReactNode {
const { ctx } = deps
const layoutExports = deps.requireModule('@deepseek-ai/dsh-client-ui-layout/client') as LayoutExports
const { AppFrame, CenterColumn, DetailsColumn } = layoutExports
const layout = ctx.layout
// ctx.get: the typed `sessions` Context merge is suspended pending the
// client/host declaration-collision arbitration (runtime's merge note).
const sessions = ctx.get('sessions') as SessionsService | undefined
if (sessions === undefined) throw new Error('shell assembly: sessions service unavailable')
// Whitelist closure: the four layout-owned top slots, granted to the shell assembler.
const slots = scopedSlots(ctx.slots.core, 'sidebar', 'conversation', 'details', 'conversation.empty')
// Stable references — created once per assembly, never per render.
const rootBinding = { ctx }
const useCurrent = (): SessionId | undefined => layout.current.useSelector((s) => s.sessionId)
const useSidebar = layout.sidebar.useSelector
const useDetails = layout.details.useSelector
const setSidebarWidth = (px: number): void => { layout.setSidebarWidth(px) }
const setDetailsWidth = (px: number): void => { layout.setDetailsWidth(px) }
const renderBody = (id: SessionId): ReactNode => (
<>
<CenterColumn>{slots.renderSlot('conversation', { sessionId: id })}</CenterColumn>
<DetailsColumn>{slots.renderSlot('details', { sessionId: id })}</DetailsColumn>
</>
)
// No selected session: the conversation.empty root slot carries EmptyState
// (ui-conversation registers it); the fallback keeps the grid shape until
// that owner lands.
const renderEmpty = (): ReactNode => (
<>
<CenterColumn>{slots.renderSlot('conversation.empty', {}, { fallback: null })}</CenterColumn>
<DetailsColumn />
</>
)
// Provider deps speak plain string (web-react's inversion: it never imports
// runtime); the assembler re-brands at this boundary — ids entering the
// provider came from layout.current, which only holds validated SessionIds.
const SessionProvider = createSessionProvider({
useCurrent,
resolveBinding: (id) => sessions.binding(id as SessionId),
renderBody: (id) => renderBody(id as SessionId),
})
return () => (
<RootBindingProvider value={rootBinding}>
<AppFrame
useSidebar={useSidebar}
useDetails={useDetails}
setSidebarWidth={setSidebarWidth}
setDetailsWidth={setDetailsWidth}
sidebar={slots.renderSlot('sidebar', {})}
>
<SessionProvider renderEmpty={renderEmpty} />
</AppFrame>
</RootBindingProvider>
)
return () => ctx.slots.renderSlot('root', {})
}
+8 -1
View File
@@ -11,6 +11,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 { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import { createClientLoader, type ClientLoaderOptions } from '@deepseek-ai/dsh-client-runtime/loader'
import { AppRoot } from './AppRoot.tsx'
import { buildRenderApp } from './app.tsx'
@@ -59,7 +60,13 @@ export function bootWebShell(el: HTMLElement, seams?: BootSeams): () => void {
loader.start()
loader.settled().then(
() => { settled.flip() },
() => {
// The renderer install is a shell-boot act, but ctx.slots exists only
// once the runtime plugin loaded — so it lands here, after settled and
// before the flip that lets renderApp call renderSlot('root').
ctx.slots.install(createSlotRenderer())
settled.flip()
},
() => { /* stay on the loading page; failures render from loader.status */ },
)
return () => { root.unmount() }
+4 -1
View File
@@ -9,7 +9,10 @@ import { afterEach, describe, expect, it } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
afterEach(cleanup)
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-web-react'
// 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'
import type { LoaderStatus } from '@deepseek-ai/dsh-client-runtime/client'
import { AppRoot } from '@deepseek-ai/dsh-client-web/src/AppRoot.tsx'
+110 -87
View File
@@ -3,83 +3,82 @@
* bootWebShell over the REAL client loader in jsdom (runScripts:dangerously —
* the loader's <script> execute path runs for real): fetch is stubbed to
* serve fake bundle text, everything else is production code — seeded module
* table, DSHClientProxy handoff, inject topology, settled flip, one-pass
* switch to the assembled UI, and the fail-loud path — through the loader's
* fetch/execute seams (jsdom's <script> vm context cannot reach the test
* window, so execute is indirect eval). The fake plugins pull the REAL
* SlotCore from the seeded ui-slots module; full-fidelity plugin content
* belongs to the apps/web e2e.
* table, DSHClientProxy handoff, inject topology, renderer install after
* settled, the one-line renderSlot('root') shell, and the fail-loud paths
* through the loader's fetch/execute seams (jsdom's <script> vm context
* cannot reach the test window, so execute is indirect eval). The fake
* runtime is the REAL SlotsService mounted by the real runtime plugin shape;
* full-fidelity plugin content belongs to the apps/web e2e.
*/
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'
interface BootWindow extends Window {
__DSH_BOOT__?: { plugins: { id: string; url: string; inject: string[]; immediately?: boolean }[] }
DSHClientProxy?: unknown
__TEST_NAV__?: { sessionId?: string; viewFor: Record<string, string> }
__TEST_SLOTS_SERVICE__?: unknown
}
const win = window as unknown as BootWindow
/** Fake runtime half: real SlotCore behind a minimal slots service + sessions stub. */
/**
* Fake runtime half: mounts the REAL SlotsService (built-in 'root', ledger,
* install/renderSlot) plus a minimal sessions face for the renderer host.
* The runtime package is not a seeded library (in production it arrives as a
* bundle), so the spec hands the real class in through a window global — the
* plugin body and everything downstream stay production code.
*/
const RUNTIME_STUB = `
window.DSHClientProxy.loadPlugin({
id: 'fake-runtime',
factory: (require) => {
const { SlotCore } = require('@deepseek-ai/dsh-client-ui-slots')
const { createSnapshotStore } = require('@deepseek-ai/dsh-client-web-react')
const SlotsService = window.__TEST_SLOTS_SERVICE__
const { createSnapshotStore } = require('@deepseek-ai/dsh-client-web-react/store')
return {
apply: (ctx) => {
const core = new SlotCore()
ctx.provide('slots', { core, define: (k, s) => core.define(k, s), register: (k, c, o) => core.register(k, c, o) })
const binding = {
sessionId: 's1',
session: { useSelector: (sel) => sel({}) },
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: createSnapshotStore({ ids: ['s1'], byId: { s1: { id: 's1', title: 'S1', running: false, updatedAt: 1 } } }),
binding: (id) => (id === 's1' ? binding : undefined),
list,
cell: (id) => (id === 's1' ? { sessionId: 's1', useSelector: (sel) => sel({}) } : undefined),
})
},
}
},
})`
/** Fake layout half: real slot specs + the export surface the shell assembly consumes. */
/** Fake layout half: ONE terminal register() call — occupy 'root', declare a
* child, seat a store factory, expose the store round trip as a probe. */
const LAYOUT_STUB = `
window.DSHClientProxy.loadPlugin({
id: 'fake-layout',
factory: (require) => {
const React = require('react')
const { createSnapshotStore } = require('@deepseek-ai/dsh-client-web-react')
const { defineStore } = require('@deepseek-ai/dsh-client-web-react')
return {
inject: ['slots'],
AppFrame: (props) => {
const sw = props.useSidebar((st) => st.width)
const dw = props.useDetails((st) => st.width)
return React.createElement('div', {
'data-testid': 'fake-frame',
'data-widths': sw + 'x' + dw,
onClick: () => { props.setSidebarWidth(311); props.setDetailsWidth(411) },
}, props.sidebar, props.children)
},
CenterColumn: (props) => React.createElement('div', null, props.children),
DetailsColumn: (props) => React.createElement('div', null, props.children),
apply: (ctx) => {
const sidebar = createSnapshotStore({ open: true, width: 300 })
const details = createSnapshotStore({ open: false, width: 360 })
ctx.reflect.provide('layout', {
current: createSnapshotStore(window.__TEST_NAV__ ?? { sessionId: 's1', viewFor: {} }),
sidebar, details,
setSidebarWidth: (px) => { sidebar.update((d) => { d.width = px }) },
setDetailsWidth: (px) => { details.update((d) => { d.width = px }) },
const createProbeStore = () => defineStore({
init: () => ({ sidebar: 300, details: 360 }),
actions: {
setSidebar: (d, px) => { d.sidebar = px },
setDetails: (d, px) => { d.details = px },
},
})
ctx.slots.register({
name: 'root',
children: { 'probe.child': { kind: 'single', scope: 'root' } },
store: createProbeStore,
}, (props) => {
const sw = props.useStore((st) => st.sidebar)
const dw = props.useStore((st) => st.details)
return React.createElement('div', {
'data-testid': 'fake-frame',
'data-widths': sw + 'x' + dw,
onClick: () => { props.actions.setSidebar(311); props.actions.setDetails(411) },
}, props.renderSlot('probe.child', {}))
})
ctx.slots.define('sidebar', { kind: 'single', scope: 'root' })
ctx.slots.define('conversation', { kind: 'single', scope: 'session' })
ctx.slots.define('details', { kind: 'single', scope: 'session' })
ctx.slots.define('conversation.empty', { kind: 'single', scope: 'root' })
ctx.slots.core.register('conversation', () => React.createElement('div', { 'data-testid': 'conv-body' }))
},
}
},
@@ -113,63 +112,60 @@ async function flushLoader(): Promise<void> {
for (let i = 0; i < 10; i++) await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
}
function bootPlugins(): { id: string; url: string; inject: string[]; immediately?: boolean }[] {
return [
{ id: 'fake-runtime', url: '/plugins/fake-runtime.js', inject: [], immediately: true },
{ id: LAYOUT_ID, url: '/plugins/fake-layout.js', inject: ['fake-runtime'] },
]
}
function fakeBundles(): Record<string, string> {
return {
'/plugins/fake-runtime.js': RUNTIME_STUB,
'/plugins/fake-layout.js': LAYOUT_STUB.replace("id: 'fake-layout'", `id: '${LAYOUT_ID}'`),
}
}
afterEach(() => {
delete win.__DSH_BOOT__
delete win.DSHClientProxy
delete win.__TEST_NAV__
delete win.__TEST_SLOTS_SERVICE__
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). */
function seedSlotsService(): void {
win.__TEST_SLOTS_SERVICE__ = SlotsService
}
describe('bootWebShell (real loader + real script execution)', () => {
it('loading page → settled → assembled UI in one pass; unmount clears the tree', async () => {
win.__DSH_BOOT__ = {
plugins: [
{ id: 'fake-runtime', url: '/plugins/fake-runtime.js', inject: [], immediately: true },
{ id: LAYOUT_ID, url: '/plugins/fake-layout.js', inject: ['fake-runtime'] },
],
}
it('loading page → settled → renderer installed → assembled UI in one pass; unmount clears the tree', async () => {
win.__DSH_BOOT__ = { plugins: bootPlugins() }
seedSlotsService()
const el = mountPoint()
let unmount: (() => void) | undefined
const s = seams({
'/plugins/fake-runtime.js': RUNTIME_STUB,
'/plugins/fake-layout.js': LAYOUT_STUB.replace("id: 'fake-layout'", `id: '${LAYOUT_ID}'`),
})
act(() => { unmount = bootWebShell(el, s) })
act(() => { unmount = bootWebShell(el, seams(fakeBundles())) })
expect(el.textContent).toContain('HARNESS')
expect(el.querySelector('[data-testid="fake-frame"]')).toBeNull()
await flushLoader()
expect(el.querySelector('[data-testid="fake-frame"]')).not.toBeNull()
expect(el.textContent).not.toContain('HARNESS')
// Selected session: SessionProvider resolved the binding and renderBody
// mounted the conversation slot content into the center column.
expect(el.querySelector('[data-testid="conv-body"]')).not.toBeNull()
act(() => { unmount!() })
expect(el.childElementCount).toBe(0)
})
it('no selected session: renderEmpty keeps the grid and forwards width setters', async () => {
win.__TEST_NAV__ = { viewFor: {} }
win.__DSH_BOOT__ = {
plugins: [
{ id: 'fake-runtime', url: '/plugins/fake-runtime.js', inject: [], immediately: true },
{ id: LAYOUT_ID, url: '/plugins/fake-layout.js', inject: ['fake-runtime'] },
],
}
it('store seat round-trips through the entry props (useStore + actions)', async () => {
win.__DSH_BOOT__ = { plugins: bootPlugins() }
seedSlotsService()
const el = mountPoint()
const s = seams({
'/plugins/fake-runtime.js': RUNTIME_STUB,
'/plugins/fake-layout.js': LAYOUT_STUB.replace("id: 'fake-layout'", `id: '${LAYOUT_ID}'`),
})
act(() => { bootWebShell(el, s) })
act(() => { bootWebShell(el, seams(fakeBundles())) })
await flushLoader()
const frame = el.querySelector('[data-testid="fake-frame"]')
expect(frame).not.toBeNull()
// Empty path: no conversation body (nothing registered into conversation.empty → fallback null).
expect(el.querySelector('[data-testid="conv-body"]')).toBeNull()
// Width setter/selector pass-through (assembly closures over ctx.layout).
// Width write/read round trip through the framework-delivered store share.
expect((frame as HTMLElement).dataset['widths']).toBe('300x360')
act(() => { (frame as HTMLElement).click() })
expect((frame as HTMLElement).dataset['widths']).toBe('311x411')
@@ -184,17 +180,44 @@ describe('bootWebShell (real loader + real script execution)', () => {
expect(el.textContent).toContain('absent-plugin')
expect(el.querySelector('[data-testid="fake-frame"]')).toBeNull()
})
})
describe('buildRenderApp — assembly guards', () => {
it('throws loud when the sessions service is absent', async () => {
const { buildRenderApp } = await import('@deepseek-ai/dsh-client-web')
const { Context } = await import('cordis')
const ctx = new Context()
ctx.reflect.provide('layout', {})
expect(() => buildRenderApp({
ctx,
requireModule: () => ({ AppFrame: () => null, CenterColumn: () => null, DetailsColumn: () => null }),
})).toThrow(/sessions service unavailable/)
it("fail loud: rendering with no 'root' registration throws through the shell error surface", async () => {
// Runtime loads (slots service present, renderer installed) but no layout
// entry ever registers into 'root' — the ctx-level renderSlot must throw.
win.__DSH_BOOT__ = {
plugins: [{ id: 'fake-runtime', url: '/plugins/fake-runtime.js', inject: [], immediately: true }],
}
seedSlotsService()
const el = mountPoint()
// React logs the render error before the boundary rethrow reaches us — keep the spec output clean.
const consoleError = console.error
console.error = () => {}
try {
act(() => { bootWebShell(el, seams({ '/plugins/fake-runtime.js': RUNTIME_STUB })) })
let thrown: unknown
try {
await flushLoader()
} catch (error) {
thrown = error
}
expect(String(thrown)).toMatch(/'root' has no registration/)
} finally {
console.error = consoleError
}
})
})
describe('buildRenderApp — assembly contract', () => {
it('is exactly the ctx-level root render call (fail-loud before install)', async () => {
const { buildRenderApp } = await import('@deepseek-ai/dsh-client-web')
const { Context } = await import('cordis')
const { SlotsService } = await import('@deepseek-ai/dsh-client-runtime/client')
const ctx = new Context()
const fiber = ctx.plugin(SlotsService)
await fiber.await()
const renderApp = buildRenderApp({ ctx, requireModule: () => undefined })
expect(renderApp).toBeTypeOf('function')
// No renderer installed: the one-line shell must surface the boot-order error.
expect(() => renderApp()).toThrow(/renderer not installed/)
})
})