From 1b0ea07bceff6a8b1be8545d8d5455841298dbd9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:40:30 +0800 Subject: [PATCH] =?UTF-8?q?refactor(gui):=20slot=20system=20standard=20?= =?UTF-8?q?=E2=80=94=20single=20register,=20four=20props=20shares,=20frame?= =?UTF-8?q?work=20store=20seat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (owner params + session/global standard kits via declare-merge), PropsRenderSlots, PropsStore, 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. --- ...7-19-gui-web-client-architecture.i18n.yaml | 4 +- .../2026-07-19-gui-web-client-architecture.md | 27 +- ...26-07-19-gui-web-client-architecture.zh.md | 27 +- ...2-slot-type-chain-implementation.i18n.yaml | 4 +- ...26-07-22-slot-type-chain-implementation.md | 100 ++- ...07-22-slot-type-chain-implementation.zh.md | 100 ++- .../2026-07-19-web-styling-system.i18n.yaml | 4 +- .../process/2026-07-19-web-styling-system.md | 2 +- .../2026-07-19-web-styling-system.zh.md | 2 +- .../2026-07-20-gui-testing-system.i18n.yaml | 4 +- .../process/2026-07-20-gui-testing-system.md | 2 +- .../2026-07-20-gui-testing-system.zh.md | 2 +- docs/web-styling.md | 2 +- packages/client/AGENTS.md | 61 +- packages/client/i18n/src/client/index.ts | 7 +- packages/client/runtime/src/client/index.ts | 36 +- .../client/runtime/src/client/loader/index.ts | 2 +- .../runtime/src/client/sessions/service.ts | 93 ++- packages/client/runtime/src/client/slots.ts | 339 ++++++++-- .../client/runtime/tests/client-apply.spec.ts | 3 + .../client/runtime/tests/invariant.spec.ts | 8 +- .../runtime/tests/sessions-service.spec.ts | 99 ++- .../runtime/tests/slots-service.spec.ts | 393 +++++++++-- packages/client/ui-conversation/README.md | 6 +- .../ui-conversation/src/client/apply.ts | 177 ++--- .../src/client/chat/ChatView.tsx | 4 +- .../src/client/chat/ToolViewOutlet.tsx | 45 +- .../src/client/contract/slots.ts | 86 +-- .../src/client/contract/toolview.ts | 11 +- .../src/client/contract/views.ts | 39 +- .../ui-conversation/src/client/index.ts | 6 +- .../ui-conversation/src/client/service.ts | 98 +-- .../src/client/skeleton/ConversationRoot.tsx | 87 ++- .../src/client/skeleton/DetailsPanel.tsx | 16 +- .../src/client/skeleton/EmptyState.tsx | 31 +- .../ui-conversation/src/client/stores.ts | 40 ++ .../tests/apply-inject.spec.tsx | 272 +++----- .../ui-conversation/tests/chat-apply.spec.tsx | 62 +- .../tests/chat-branch-tails.spec.tsx | 45 +- .../tests/chat-stats-bash-sample.spec.tsx | 1 + .../ui-conversation/tests/chat-store.spec.ts | 94 +++ .../ui-conversation/tests/chat-view.spec.tsx | 36 +- .../tests/gate-branch-tails.spec.tsx | 68 +- .../tests/selection-survival.spec.ts | 169 +++-- .../tests/service-orchestration.spec.ts | 52 +- .../tests/service-stores.spec.ts | 176 ----- .../tests/skeleton-branches.spec.tsx | 129 ++-- .../ui-conversation/tests/skeleton.spec.tsx | 159 +++-- .../tests/toolviews-type-chain.spec.ts | 10 +- .../tests/views-type-chain.spec.tsx | 11 + packages/client/ui-layout/README.md | 2 +- .../client/ui-layout/src/client/AppFrame.tsx | 88 +-- .../client/ui-layout/src/client/columns.ts | 20 +- packages/client/ui-layout/src/client/index.ts | 100 +-- .../client/ui-layout/src/client/service.ts | 142 +--- .../client/ui-layout/src/client/stores.ts | 36 + .../client/ui-layout/tests/app-frame.spec.tsx | 124 +++- packages/client/ui-layout/tests/apply.spec.ts | 28 +- .../client/ui-layout/tests/columns.spec.ts | 5 +- .../ui-layout/tests/layout-store.spec.ts | 73 ++ .../client/ui-layout/tests/service.spec.ts | 168 ++--- packages/client/ui-sidebar/README.md | 8 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 54 +- .../ui-sidebar/src/client/contract/slots.ts | 59 +- .../client/ui-sidebar/src/client/index.ts | 70 +- .../client/ui-sidebar/src/client/store.ts | 94 --- packages/client/ui-sidebar/src/client/tree.ts | 19 +- packages/client/ui-sidebar/src/invariant.ts | 8 +- .../client/ui-sidebar/tests/apply.spec.tsx | 150 ++--- .../ui-sidebar/tests/sidebar-root.spec.tsx | 82 +-- .../client/ui-sidebar/tests/store.spec.ts | 111 ---- packages/client/ui-sidebar/tests/tree.spec.ts | 24 +- packages/client/ui-slots/README.md | 21 +- packages/client/ui-slots/package.json | 2 +- packages/client/ui-slots/src/index.ts | 625 +++++++++++------- packages/client/ui-slots/src/renderer.ts | 116 ++++ packages/client/ui-slots/src/store.ts | 137 ++++ packages/client/ui-slots/tests/core.spec.ts | 320 +++++---- .../client/ui-slots/tests/surface.spec.ts | 46 +- .../client/ui-slots/tests/type-chain.spec.tsx | 278 ++++---- .../client/ui-trajectory/tests/views.spec.tsx | 98 +-- packages/client/web-react/README.md | 6 +- packages/client/web-react/src/index.ts | 42 +- .../client/web-react/src/scoped-slots.tsx | 262 +++++--- .../client/web-react/src/session-provider.tsx | 119 ++-- packages/client/web-react/src/store/index.ts | 100 ++- .../tests/scoped-slots-real-core.spec.tsx | 133 ++-- .../web-react/tests/scoped-slots.spec.tsx | 608 ++++++++++------- .../web-react/tests/session-provider.spec.tsx | 199 +++--- .../tests/stale-authorization.spec.tsx | 136 ++++ packages/client/web-react/tests/store.spec.ts | 94 ++- packages/client/web/src/app.tsx | 77 +-- packages/client/web/src/boot.tsx | 9 +- packages/client/web/tests/app-root.spec.tsx | 5 +- packages/client/web/tests/boot.spec.tsx | 197 +++--- 95 files changed, 5024 insertions(+), 3322 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/stores.ts create mode 100644 packages/client/ui-conversation/tests/chat-store.spec.ts delete mode 100644 packages/client/ui-conversation/tests/service-stores.spec.ts create mode 100644 packages/client/ui-layout/src/client/stores.ts create mode 100644 packages/client/ui-layout/tests/layout-store.spec.ts delete mode 100644 packages/client/ui-sidebar/src/client/store.ts delete mode 100644 packages/client/ui-sidebar/tests/store.spec.ts create mode 100644 packages/client/ui-slots/src/renderer.ts create mode 100644 packages/client/ui-slots/src/store.ts create mode 100644 packages/client/web-react/tests/stale-authorization.spec.tsx diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 91abd32a3e..7fba74af19 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-gui-web-client-architecture.md: 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 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 58320570f7..32ec0a371f 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -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` / `PropsRenderSlots` / `PropsStore` / 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 & StandardOf & 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` enforces the composition at the call site: the component parameter is `SlotComponent>>` (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: ` — 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`/`ChromePropsOf` 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(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` (`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` (`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 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index 744fdaa4b8..8a929a3e25 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -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` / `PropsRenderSlots` / `PropsStore` / 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 & StandardOf & OwnInjected`——owner 份额从坑位 owner 的包引用、标配份额由框架供给(session 坑:`useSession`)、注册方自己的注入份额就地声明在组件旁。`register` 在调用点强制组合:组件形参位是 `SlotComponent>>`(裸调用签名而非 `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: `——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`/`ChromePropsOf` 组合基座+扩展,无声明的视图免费得基座,ui-trajectory 的两个 entry 带真 per-view props)与**工具环**(tool 名保持开放集——无全局键表;类型强化在 entry 内部:`ToolViewProps.block` 是 runtime 定义的真 `ToolCallBlock` union,register 同 slots 一样推断注册方注入份额)。 @@ -101,7 +86,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── 胶水包就是整条 ctx↔React 边界;组件保持零框架依赖。 -- `createSnapshotStore(init, opts)`:插件自有数据与壳观看态的 store 引擎——zustand vanilla + 草稿式更新,缺省 `flush: 'sync'`(受控输入要求同 tick 回响),帧驱动 store 可选 `'raf'` 合批,可选整值 localStorage 持久化,dev 深冻结。Session 对象与快照 store 同构满足 React 消费的唯一数据契约:`ObservableSnapshot`(`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`(`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 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml index 06fed08671..b1b67af99e 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-slot-type-chain-implementation.md: 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 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md index b4ec761b97..f6c5e09d53 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md @@ -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

` (bare call signature) instead of `FC

` 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>>` where `SlotComponent

= (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` 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` 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` 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` 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` | SlotMap entry for K | `OwnerOf` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions` | +| child render | `PropsRenderSlots` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S | +| store | `PropsStore` | 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>` 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` 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

` (bare call signature) instead of `FC

` 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` 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) | diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md index 28b6e4a3db..61ad6e8604 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md @@ -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

`(裸调用签名)而非 `FC

` +一句话:**壳只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——这一次调用同时占坑、声明并授权子坑、声明 store、注入业务面;组件是纯函数,props 分四份额到达,每一份额都从各自唯一的真源自动推导。** -`register()` 以 `SlotComponent>>` 约束组件,其中 `SlotComponent

= (props: P) => ReactNode`。React 的 `FC` 携带静态字段(`propTypes`、`defaultProps`),其类型在协变位引用 `P`;两个 `FC` 实例化之间的可赋性因此连这些静态位一起查,而 bottom 型的标配份额(见裁定 4 的 `useSession: never`)使这些协变检查拒绝掉收窄它的组件——恰恰是设计想接受的那批组件。裸调用签名只走干净的参数逆变检查。组件仍是普通函数;运行时零可见差异。 +### 'root' 是唯一的先验坑 -### 2. `NoInfer` 把注册方份额的推断钉在 inject 工厂上 +`SlotsService`(client 运行时)在构造时声明 `'root'`——single/root、`owner: {}`——其 `SlotMap` 合并声明住 runtime 包(package)。壳的全部装配就是 `ctx.slots.renderSlot('root', {})`:唯一的 ctx 级渲染入口;传任何其他键、渲染器未安装、root 无人注册,一律大声失败(无 fallback)。 -`I`(注册方注入份额)必须从 `inject` 工厂的返回类型推断——唯一权威源。没有 `NoInfer` 时,TS 还会从组件参数位收集推断候选,漂移的组件(消费一个工厂并不供给的键)会静默地把 `I` 加宽到让调用通过,把漂移吸收掉而不是报出来。组件位的 `NoInfer` 移除了那个候选位,负样本⑥(owner 份额的手抄漂移件在 register 处失败)才得以成立——有推断渗漏时它会通过。将来若有人把这个 `NoInfer`「顺手简化」掉,类型链 spec 的 expect-error 位会第一个变红。 +### register 是唯一 API;children = 声明+授权+运行时 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` 只在 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` 加一层可选的第二道检查:仅当条目声明了 `children`,组件的 `slots` 面才对照授权并集校验(越界时 `slots` 坍缩为 `never`,在 register 调用处暴露)。未声明 `children` 的条目原样通过。挂点选在 `ComposedProps` 内部——即恰好在注册边界而非渲染期起效——因为 register 是条目声明与组件面两个半边同时静态可见的唯一位置;渲染期检查要为一个纯静态保证铺运行时管线。 +| 份额 | 类型 | 真源 | 内容 | +|---|---|---|---| +| 运行时 | `PropsRuntime` | K 对应的 SlotMap entry | `OwnerOf`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions` | +| 子坑渲染 | `PropsRenderSlots` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S | +| store | `PropsStore` | 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>` 推导出组件的 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`——账本、坑、贡献、渲染绑定、store 实例全部沿同一条 entry 轴生灭,跨插件重载的陈旧权威窗口由此在构造上关闭(已 dispose 的 entry 所捕获的 `renderSlot`,一进入口即抛陈旧授权(stale-authorization)错误)。 + +### 类型链实现裁定 + +register 签名里的两条硬化裁定之所以存在,是因为显然的替代方案会以具体、可复现的方式失败;将来的编辑者不应重新争论它们: + +1. **注册位用 `SlotComponent

`(裸调用签名)而非 `FC

`。** React 的 `FC` 携带静态字段(`propTypes`、`defaultProps`),其类型在协变位引用 `P`;两个 `FC` 实例化之间的可赋性检查连这些静态位一起查,会拒绝设计本想接受的组件。裸调用签名只走干净的形参逆变检查;组件仍是普通函数。 +2. **`NoInfer` 把业务份额的推断钉在 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 | 拆分让渲染权威无从强制、招来时序 bug;children 进 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 漂移(见上文裁定) | diff --git a/.agents/notes/implemented/process/2026-07-19-web-styling-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-19-web-styling-system.i18n.yaml index eacf4ef13d..9daf021e6e 100644 --- a/.agents/notes/implemented/process/2026-07-19-web-styling-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-19-web-styling-system.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-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 diff --git a/.agents/notes/implemented/process/2026-07-19-web-styling-system.md b/.agents/notes/implemented/process/2026-07-19-web-styling-system.md index c80ef0d56a..b4d647924a 100644 --- a/.agents/notes/implemented/process/2026-07-19-web-styling-system.md +++ b/.agents/notes/implemented/process/2026-07-19-web-styling-system.md @@ -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) diff --git a/.agents/notes/implemented/process/2026-07-19-web-styling-system.zh.md b/.agents/notes/implemented/process/2026-07-19-web-styling-system.zh.md index 59013a4a95..01064d4d52 100644 --- a/.agents/notes/implemented/process/2026-07-19-web-styling-system.zh.md +++ b/.agents/notes/implemented/process/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) | 中文 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml index 6b2f1de1e0..71ea20f6ee 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-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 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md index db1b47566f..fdd5c7f9d3 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md @@ -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) diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md index 691c6baf50..0ae0813374 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md +++ b/.agents/notes/implemented/process/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) | 中文 diff --git a/docs/web-styling.md b/docs/web-styling.md index d2b07ea2b5..0b6aeb55d5 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -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) 拍板,本文不重复论证。 diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 7d15bfc0ba..08290791f8 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -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-`. +## 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` (SlotMap: owner params + `useSession`/`sessionId` on session scope + `useSessions`) & `PropsRenderSlots` (children keys) & `PropsStore` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally. +4. **Hooks are framework-made only**: `useSession`, `useSessions`, `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`). 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

`, 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// # one directory per left-nav bar (sessions, rpclog, …) - sessiontabs// # one directory per session tab (conversation, gantt, …) - components/ # shared leaves (MessageText, JsonBlock, …) - hooks/ utils/ style/ # cross-cutting; not feature-owned -``` - -- `leftmenu/` must not import `leftmenu/` 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. diff --git a/packages/client/i18n/src/client/index.ts b/packages/client/i18n/src/client/index.ts index b994cccace..8fa5715047 100644 --- a/packages/client/i18n/src/client/index.ts +++ b/packages/client/i18n/src/client/index.ts @@ -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' diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 5180fce2b2..8c669a20bc 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -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 - /** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */ export type UseConversationSession = UseSession @@ -51,6 +54,25 @@ export type UseConversationSession = UseSession */ 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 + /** 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 + } +} + declare module 'cordis' { interface Events { /** diff --git a/packages/client/runtime/src/client/loader/index.ts b/packages/client/runtime/src/client/loader/index.ts index 521c597ae4..f8166add9c 100644 --- a/packages/client/runtime/src/client/loader/index.ts +++ b/packages/client/runtime/src/client/loader/index.ts @@ -17,7 +17,7 @@ * load one by one in inject topology. */ import type { Context } from 'cordis' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react' +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' diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 2b4cf9677e..430ca81478 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.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 } +/** + * 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; 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 /** 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() /** 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({ ids: [], byId: {} }) + this.selection = createSnapshotStore<{ sessionId?: SessionId }>( + {}, + { persist: { name: 'dsh.sessions.current' } }) + this.list = createSnapshotStore({ 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) } } } diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index 5b58670bd4..8c93050cd9 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -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 +} + +/** + * 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 = { + /** 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 + +/** + * 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 = + [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> + store?: StoreDecl + inject?: (...args: never[]) => Record + 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() + 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(key: K, spec: SlotSpec): () => void { + register< + K extends keyof SlotMap & string, + const D extends ChildrenDecl = Record, + H extends StoreDecl | undefined = undefined, + C extends SlotComponent = SlotComponent, + >( + options: RegisterOptions & { inject?: undefined }, + component: C + & SlotComponent & keyof SlotMap & string, HandleOf>, object>> + & RendersCheck, + ): () => void + register< + K extends keyof SlotMap & string, + I extends object, + const D extends ChildrenDecl = Record, + H extends StoreDecl | undefined = undefined, + C extends SlotComponent = SlotComponent, + >( + options: RegisterOptions & { inject: (...args: InjectParams) => I }, + component: C + & SlotComponent & keyof SlotMap & string, HandleOf>, I>> + & RendersCheck, + ): () => 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>( - // Client-context registrations have exactly one ctx shape: pin Ctx to - // ClientContext so inject factories dot services without a cast. - key: K, component: SlotComponent>>, - ...args: RegisterArgs): () => void { - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity - return this.ctx.effect(() => this._core.register(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(key: K): readonly SlotEntry[] { + renderSlot(key: K, owner: OwnerOf): ReturnType { + // 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 | 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).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) + } } diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index bb98bca98e..0baa2e8237 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -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() diff --git a/packages/client/runtime/tests/invariant.spec.ts b/packages/client/runtime/tests/invariant.spec.ts index cdfbba3f7d..708b714b2f 100644 --- a/packages/client/runtime/tests/invariant.spec.ts +++ b/packages/client/runtime/tests/invariant.spec.ts @@ -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 () => { diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index f3989c4532..7e0546335b 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -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() + 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() diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index f53a22bb0a..88d73d4ac0 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -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 = () => null -async function boot(): Promise { +/** + * 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 { 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 + clearPersisted: ReturnType +} + +/** 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']) }) - }) diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 425122958a..bca5ee9a37 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -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 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index a600789a20..266602e1b2 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -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(ctx: Context, name: string): T { return value } -/** Per-list-state cwd set (deduped, list order) for the empty-state picker. */ -const cwdsCache = new WeakMap() -function cwdsOf(state: SessionListState): readonly string[] { - let cached = cwdsCache.get(state) - if (cached === undefined) { - const seen = new Set() - 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: a real outlet with an empty - // whitelist (uncallable by type, correct runtime shape for future grants). - const emptySlots = scopedSlots(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(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 = { - 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): 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(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 = (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) } diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b8ea969829..24b2e278fe 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -122,7 +122,7 @@ function StreamingTail({ useSession, onGrow }: { export function createChatView(deps: ChatViewDeps): FC { 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 { 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]) diff --git a/packages/client/ui-conversation/src/client/chat/ToolViewOutlet.tsx b/packages/client/ui-conversation/src/client/chat/ToolViewOutlet.tsx index 7fa8b5702e..9f376f593a 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolViewOutlet.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolViewOutlet.tsx @@ -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, WeakMap>() +/** 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, Map>() -function cachedInject(inject: ToolViewInject, binding: SessionBinding): object { - let perBinding = injectCache.get(inject) - if (!perBinding) { - perBinding = new WeakMap() - injectCache.set(inject, perBinding) +function cachedInject(inject: ToolViewInject, 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; inject: ToolViewInject; viewProps: ToolViewProps -}) { - const binding = useSessionBinding() - const injected = cachedInject(inject, binding) - return -} - 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 }> {resolved.inject === undefined ? - : } + : } ) } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 276ae1ec30..a7001fb21e 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -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 (framework standard kit) & PropsStore + * (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 + +/** + * 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 & 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 - 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 & 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 - actions: { startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise } + /** The create → navigate → first-send chain, in one service call. */ + startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise } -/** 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 diff --git a/packages/client/ui-conversation/src/client/contract/toolview.ts b/packages/client/ui-conversation/src/client/contract/toolview.ts index 5271b7658c..e2f652e19a 100644 --- a/packages/client/ui-conversation/src/client/contract/toolview.ts +++ b/packages/client/ui-conversation/src/client/contract/toolview.ts @@ -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 = (b: SessionBinding) => I +export type ToolViewInject = (sessionId: SessionId) => I /** Options accepted by the toolview registry's register; `I` is inferred from the inject factory. */ export interface ToolViewOptions { diff --git a/packages/client/ui-conversation/src/client/contract/views.ts b/packages/client/ui-conversation/src/client/contract/views.ts index 611cc40341..0d91e74855 100644 --- a/packages/client/ui-conversation/src/client/contract/views.ts +++ b/packages/client/ui-conversation/src/client/contract/views.ts @@ -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 + /** Chat store read face (selection is the only slice views consume today). */ + useStore: SnapshotSelectorHook actions: { openDetails(t: SelectionTarget): void; loadOlder(): void } - /** Chat has no delegated sub-slots in P-I (toolviews go through the named registry). */ - slots: ScopedSlots } diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 91b9c27c95..5605971c54 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -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. diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 40aa1b93cc..9c9ee639b8 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -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>() - private readonly draftStores = new Map>() 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 { - return this.scopeStore(this.selections, 'selection', - () => createSnapshotStore(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 { - return this.scopeStore(this.draftStores, 'drafts', (id) => { - const key = `dsh.conversation.draft.${id}` - const store = createSnapshotStore(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( - map: Map>, op: string, - make: (id: SessionId) => SnapshotStore): SnapshotStore { - 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) -} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 540b76bda7..cf0aeec947 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -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(() => ({ + 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 &&
} + + {Footer !== undefined &&