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 9e671e3672..0ed8f5d7a4 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: 1e9bd711e8316e2556fe238eb0a20d76e1d0d5b1 -2026-07-22-slot-type-chain-implementation.zh.md: 0eab839d033faac2f2c3356900c7ca1cd69d2dc9 +2026-07-22-slot-type-chain-implementation.md: 65b4ebb475fe34d71d8d3a08878b40103b3c95bd +2026-07-22-slot-type-chain-implementation.zh.md: 4c55171ca0118782568e17f349f83d6cf9211617 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 1e9bd711e8..65b4ebb475 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 @@ -34,7 +34,7 @@ ctx.slots.register({ 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. -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. +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; a chain registration missing its `select`. `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"). @@ -43,12 +43,20 @@ Parity rule: **the declaring entry holds the exclusive right to render its child | 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 | +| child render | `PropsRenderSlots` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` | | store | `PropsStore` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) | | business | `I` | inject return type | plain data + callbacks (hooks banned) | `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 chain kind: entries self-nominate, first match renders + +The fourth `SlotKind`, `'chain'`, inverts routing authority relative to `keyed`: a keyed dispatch site picks its occupant by `entryKey`, while a chain entry nominates itself — the owner dispatches one common currency of owner props and never learns who takes over, so a new takeover package registers with zero owner edits. A chain registration carries a `select` pure selector (`ChainSelect`: `(owner) => matched | null`) and an optional `priority` (ascending; ties keep registration = assembly order — the deployment-controllable inject topology — under the same stable sort as list `order`); registering without `select` is one of the loud-at-load cases above. At render, the outlet runs the selectors in chain order: the first non-null return elects its entry and the returned value joins the component's props as `matched` (the component never re-derives its own match), `null` passes the turn to the next entry, and all-null renders the owner's fallback body (`ChainRenderOpts`). + +The decline decision lives in `select`, never in a mounted component probing its own props: a component that mounts only to render null still runs its hooks and effects for nothing, and the resulting mount/unmount churn breaks memoization and React key semantics, whereas a selector is a pure function — unit-testable, zero mount side effects — the same discipline as "presentation methods are pure functions of `args`". Purity is the selector's contract: it reads no external mutable state and produces no side effects, so the routing decision is entirely a function of the owner props and safe to run on every dispatch. Selectors route; they never mint — per-dispatch object construction would churn identity every render, so wrapping a matched value in a richer face happens inside the elected component (`useMemo` keyed on `matched`). + +In the type chain, a chain entry's SlotMap shape is `{ kind: 'chain'; scope; owner }` with `owner` as the chain's currency; `M` — the `matched` prop's type — is inferred from the select return (a selector narrowing a union member types `matched` automatically), and the component position stays out of `M` inference, the same NoInfer ruling that pins the inject share (rulings below). On the owner side, `renderSlotChain(key, owner, { fallback })` joins `renderSlot` in the `PropsRenderSlots` share, its key domain statically narrowed to the chain-kind keys of the entry's children declaration (`ChainKeysOf`); the dispatch site is one line and holds no derivation or routing logic of its own. + ### The store seat: framework engine, registrant schema The framework owns exactly one subscription machine: the snapshot store engine (zustand vanilla + immer + optional localStorage persistence) lives in the **runtime package** (`./client` main entry — no subpath), producing bare observable sources; web-react binds them into hooks at the outlet (per-source cached uSES binding). What a store *contains* is the registrant's declaration, written as a factory so no module-level handle exists (a module-scoped handle would be a de-facto singleton surviving plugin reloads): @@ -93,7 +101,7 @@ Two hardening decisions in the register signature exist because the obvious alte ## Consequences -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. +Render authority is enforceable rather than conventional: who renders what is a load-time fact, and auditing the UI structure = reading the register calls; for chain slots, WHO renders is additionally a render-time fact, but the deciding selectors are register-site declarations, so the audit surface stays 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 @@ -107,3 +115,5 @@ Render authority is enforceable rather than conventional: who renders what is a | 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) | +| Keyed dispatch with owner-side routing for takeover slots | The owner accumulates per-entry contracts and a hardcoded routing table (`find` + `entryKey` per takeover); the chain currency keeps new takeover registrations at zero owner edits | +| Components declining by rendering null | Declining requires mounting first — hooks and effects run for nothing, and mount/unmount churn breaks memoization and key semantics; a pure selector decides without a component instance | 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 0eab839d03..4c55171ca0 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 @@ -34,7 +34,7 @@ ctx.slots.register({ 不存在独立的坑位定义 API。`children` 对象同时做两件事:**把子坑声明出来**,并**授权本组件渲染它们**——坑是渲染树上的一个洞,因为有人要渲染它才存在,所以坑的生命周期就是声明它的 entry 的生命周期(entry 一经 dispose(资源释放),坑随之消亡、坑内既有贡献清空)。children 的值是运行时 spec(`kind`/`scope` 驱动 outlet 的迭代形态与 binding 选择;`SlotMap` 是纯类型、运行时即被擦除,这正是键数组形行不通的原因),并与对应 `SlotMap` entry 静态对齐校验——类型与值在同一点声明、交叉验证。 -对等原则:**声明子坑的 entry 独占渲染这些子坑的权力**,全部在 register 时结清(配置错误在装载时大声失败;渲染热径零校验)。装载即炸的情形:第二个 entry 声明已被声明的坑;向未声明的坑 register;同一个 store 句柄挂到两个 scope 之下。 +对等原则:**声明子坑的 entry 独占渲染这些子坑的权力**,全部在 register 时结清(配置错误在装载时大声失败;渲染热径零校验)。装载即炸的情形:第二个 entry 声明已被声明的坑;向未声明的坑 register;同一个 store 句柄挂到两个 scope 之下;chain 注册缺 `select`。 `SlotMap` 声明合并仍是类型权威,且 entry 只声明自己的轴加 **owner 份额**——注册方注入的 props 永不进入全局表(「谁注入的,类型归谁」)。 @@ -43,12 +43,20 @@ ctx.slots.register({ | 份额 | 类型 | 真源 | 内容 | |---|---|---|---| | 运行时 | `PropsRuntime` | K 对应的 SlotMap entry | `OwnerOf`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions` | -| 子坑渲染 | `PropsRenderSlots` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S | +| 子坑渲染 | `PropsRenderSlots` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` | | store | `PropsStore` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) | | 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) | 凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双向锁的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。 +### chain kind:entry 自荐,首中即渲 + +第四种 `SlotKind`——`'chain'`——把路由权相对 `keyed` 反转:keyed 的分派现场以 `entryKey` 点选占坑者,chain 则由 entry 自荐——owner 只分派一份通用货币形态的 owner props,永远不知道谁来接管,新的接管包注册进来 owner 零改动。chain 注册携带一个 `select` 纯选择器(`ChainSelect`:`(owner) => matched | null`)与可选的 `priority`(升序;同值保持注册序 = 装配序——部署可控的 inject 拓扑——复用 list `order` 的同一稳定排序);注册缺 `select` 即上文装载即炸情形之一。渲染时 outlet 按链序依次执行各 select:首个非 null 返回值当选,该值以 `matched` 并入组件 props(组件绝不自行重新推导匹配);返回 `null` 则轮到下一个 entry;全 null 则渲染 owner 的 fallback 体(`ChainRenderOpts`)。 + +「不接」的判定住在 `select` 里,绝不在挂载后的组件里自探 props:组件为了渲染 null 也得先挂载,其 hook 与 effect 全部白跑,随之而来的挂载/卸载抖动还会破坏 memo 化与 React key 语义;而选择器是纯函数——可单测、零挂载副作用——与「presentation methods are pure functions of `args`」是同一条纪律。纯,就是选择器的契约:不读外部可变状态、不产副作用,路由判定因此完全是 owner props 的函数,每次分派都可安全执行。选择器只做路由、绝不铸对象——按分派逐次构造对象会让引用每次渲染都换新;把匹配值包成更丰富的面这件事,发生在当选组件内部(以 `matched` 为依赖的 `useMemo`)。 + +类型链上,chain entry 的 SlotMap 形状是 `{ kind: 'chain'; scope; owner }`,`owner` 即链的货币;`M`——`matched` prop 的类型——从 select 返回值推导(选择器收窄 union 成员时,`matched` 类型自动随之收窄),且组件位不参与 `M` 的推断,与钉住 inject 份额的 NoInfer 裁定同源(见下文裁定)。owner 侧,`renderSlotChain(key, owner, { fallback })` 与 `renderSlot` 同住 `PropsRenderSlots` 份额,其键域静态收窄到本 entry children 声明中 chain kind 的键(`ChainKeysOf`);分派现场只有一行,不含任何自有的派生或路由逻辑。 + ### store 席位:引擎归框架,schema 归注册方 框架拥有恰好一台订阅机械:快照 store 引擎(zustand vanilla + immer + 可选 localStorage 持久化)住 **runtime 包**(`./client` 主出口——无子路径),产出裸的可观察源;web-react 在 outlet 处把它们绑定成 hook(按源缓存的 uSES 绑定)。store 里*装什么*是注册方的声明,且必须写成工厂函数,使模块级句柄根本无从存在(模块级句柄会成为跨插件重载存活的事实单例): @@ -93,7 +101,7 @@ register 签名里的两条硬化裁定之所以存在,是因为显然的替 ## Consequences -渲染权威从此可强制执行,而非仅靠约定:谁渲染什么是装载期事实,审计 UI 结构 = 通读 register 调用。每个 props 面都从单一真源静态推导(SlotMap entry、children 键集、store 工厂、inject 返回值),schema 变更由编译器传播,而不靠 grep。插件不再自带任何订阅机械——store 生命周期(每会话实例、dispose、持久化)是钉在 entry 轴上的框架语义。代价:注册选项稠密(children spec 对象);框架背上实打实的推断机械(`defineStore` 的 init/actions 同轮推断可能需要柯里化兜底);编译期双向锁意味着原型阶段的漂移直接是硬错误,而非警告。 +渲染权威从此可强制执行,而非仅靠约定:谁渲染什么是装载期事实,审计 UI 结构 = 通读 register 调用;对 chain 坑,「谁来渲染」额外多出一层渲染期事实,但做决定的选择器全是 register 现场的声明,审计面仍是 register 调用。每个 props 面都从单一真源静态推导(SlotMap entry、children 键集、store 工厂、inject 返回值),schema 变更由编译器传播,而不靠 grep。插件不再自带任何订阅机械——store 生命周期(每会话实例、dispose、持久化)是钉在 entry 轴上的框架语义。代价:注册选项稠密(children spec 对象);框架背上实打实的推断机械(`defineStore` 的 init/actions 同轮推断可能需要柯里化兜底);编译期双向锁意味着原型阶段的漂移直接是硬错误,而非警告。 ## Alternatives considered @@ -107,3 +115,5 @@ register 签名里的两条硬化裁定之所以存在,是因为显然的替 | 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 | | 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 | | 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) | +| 接管坑用 keyed 分派 + owner 侧路由 | owner 会不断攒下逐 entry 契约与硬编码路由表(每种接管一份 `find` + `entryKey`);chain 货币让新增接管注册保持 owner 零改动 | +| 组件靠渲染 null 表示不接 | 不接也得先挂载——hook 与 effect 白跑,挂载/卸载抖动破坏 memo 化与 key 语义;纯选择器无需组件实例即可裁决 | diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b90812916a..93d2e2846d 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -34,9 +34,12 @@ export type { } from './contract/store.ts' export type { AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot, - PendingInteraction, RunningToolCall, SteeringMessageNode, + RunningToolCall, SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' +// PendingWait is a value export: tests construct fixture waits directly. +export { PendingWait } from './sessions/pending.ts' +export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts' export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' // ---- Narrowed aliases (the single narrowing point of the slot type chain: diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 18c5501972..1e06fad70d 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -4,7 +4,8 @@ // string here (narrow to real brands when convenient). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcError, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { PendingInteraction } from './pending.ts' /** Assistant content blocks sorted by what the UI cares about * (text body / collapsible reasoning / tool-call card head / other fallback). */ @@ -121,11 +122,6 @@ export interface RunningToolCall { callView: ToolCallView | null } -/** Approval/question placeholder cards (visible, not answerable; - * rpcId = the requested frame's envelope id, the future respond backfill key). */ -export type PendingInteraction = - | { kind: 'approval'; rpcId: RpcId; approvalId: string; toolName: string; callId?: string; reason?: string } - | { kind: 'question'; rpcId: RpcId; questions: readonly unknown[] } /** In-progress assistant output (chunk accumulator product). */ export interface PartialAssistant { diff --git a/packages/client/runtime/src/client/sessions/pending.ts b/packages/client/runtime/src/client/sessions/pending.ts new file mode 100644 index 0000000000..ba69a6951e --- /dev/null +++ b/packages/client/runtime/src/client/sessions/pending.ts @@ -0,0 +1,79 @@ +// PendingWait: the carrier-protocol half of a pending host interaction. The runtime owns only +// envelope knowledge (rpcId backfill into a client-response); domain result encoding belongs to +// the interaction's consumer package. + +import type { + ClientResponse, MuxFrame, RpcId, RpcReceipt, SessionId, +} from '@deepseek-ai/dsh-client-connection/client' + +/** Kind-keyed payload map: the requested frame's domain fields (envelope fields stripped). */ +export interface PendingPayloads { + approval: Omit, 'type' | 'sessionId'> + question: Omit, 'type' | 'sessionId'> +} + +/** Pending-interaction discriminant (the keys of PendingPayloads). */ +export type PendingKind = keyof PendingPayloads + +/** Kind-discriminated union of concrete waits: narrowing on `kind` types `payload`. */ +export type PendingInteraction = { [K in PendingKind]: PendingWait }[PendingKind] + +/** Key prefixes, one per kind (the key doubles as the Session pending-map key). */ +const KEY_PREFIX: Record = { approval: 'a', question: 'q' } + +/** + * One pending host-owned interaction wait: an immutable render face + * (kind/key/sessionId/payload) plus the response carrier. respond() backfills + * the requested frame's rpcId into a client-response envelope — no consumer + * ever sees the raw rpcId. Settlement is expressed only by pending-list + * membership (the settled flag is a fail-loud guard, not a render input). + */ +export class PendingWait { + /** Interaction kind (union discriminant). */ + readonly kind: K + /** Opaque render identity, `:` — stable across baseline replay, usable as a React key. */ + readonly key: string + /** Owning session. */ + readonly sessionId: SessionId + /** The requested frame's domain fields, verbatim. */ + readonly payload: PendingPayloads[K] + #settled = false + readonly #rpcId: RpcId + readonly #respond: (message: ClientResponse) => Promise + + /** + * Minted by Session on a requested frame (public construction is the test-fixture path). + * @param kind - interaction kind. + * @param rpcId - the requested frame's stable envelope id (kept private; respond echoes it). + * @param sessionId - owning session. + * @param payload - the requested frame's domain fields. + * @param respond - the client-response carrier (api.respond). + */ + constructor( + kind: K, rpcId: RpcId, sessionId: SessionId, payload: PendingPayloads[K], + respond: (message: ClientResponse) => Promise, + ) { + this.kind = kind + this.key = `${KEY_PREFIX[kind]}:${rpcId}` + this.sessionId = sessionId + this.payload = payload + this.#rpcId = rpcId + this.#respond = respond + } + + /** + * Send a result for this wait: wraps it into the client-response envelope + * with the rpcId backfilled. Throws synchronously once settled. + * @param result - the result shell (ok value / error envelope), domain-encoded by the caller. + * @returns the carrier receipt. + */ + respond(result: ClientResponse['result']): Promise { + if (this.#settled) throw new Error(`pending wait ${this.key} is already settled`) + return this.#respond({ type: 'client-response', rpcId: this.#rpcId, result }) + } + + /** Session-only settlement mark (the authoritative resolved frame arrived); respond() throws afterwards. */ + markSettled(): void { + this.#settled = true + } +} diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 761aca96c2..6251391df0 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -5,12 +5,17 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client' +import type { + HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, + SessionId, ToolEventView, +} from '@deepseek-ai/dsh-client-connection/client' import { transportError } from '@deepseek-ai/dsh-client-connection/client' import type { ObservableSnapshot } from '../contract/store.ts' import type { - ConversationNode, ConversationSnapshot, OpenState, PendingInteraction, PromptError, RunningToolCall, + ConversationNode, ConversationSnapshot, OpenState, PromptError, RunningToolCall, } from './conversation.ts' +import type { PendingInteraction } from './pending.ts' +import { PendingWait } from './pending.ts' import { FoldAdapter } from './fold-adapter.ts' import { Notifier } from './notifier.ts' import { PartialAccumulator } from './partial.ts' @@ -183,7 +188,9 @@ export class Session implements ObservableSnapshot { this.events = [] this.views = [] this.baseSeq = 0 - this.pending.clear() // the subscribed baseline replay re-sends still-pending requested frames verbatim + // Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim + // (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host. + this.pending.clear() this.pendingRev++ this.subscribedLastSeq = null this.liveBuffer = [] @@ -229,33 +236,27 @@ export class Session implements ObservableSnapshot { return // pure baseline bookkeeping, no visible change } case 'approval/requested': { - this.pending.set(`a:${rpcId}`, { - kind: 'approval', rpcId, approvalId: frame.approvalId, toolName: frame.toolName, - ...(frame.callId !== undefined ? { callId: frame.callId } : {}), - ...(frame.reason !== undefined ? { reason: frame.reason } : {}), - }) - this.pendingRev++ + const { type: _type, sessionId: _sid, ...payload } = frame + this.mint(new PendingWait('approval', rpcId, this.sessionId, payload, m => this.api.respond(m))) this.notifier.markDirty() return } case 'approval/resolved': { - for (const [key, item] of this.pending) { - if (item.kind === 'approval' && item.approvalId === frame.approvalId) { - this.pending.delete(key) - this.pendingRev++ - } + for (const item of this.pending.values()) { + if (item.kind === 'approval' && item.payload.approvalId === frame.approvalId) this.settle(item) } this.notifier.markDirty() return } case 'question/requested': { - this.pending.set(`q:${rpcId}`, { kind: 'question', rpcId, questions: frame.questions }) - this.pendingRev++ + const { type: _type, sessionId: _sid, ...payload } = frame + this.mint(new PendingWait('question', rpcId, this.sessionId, payload, m => this.api.respond(m))) this.notifier.markDirty() return } case 'question/resolved': { - if (this.pending.delete(`q:${frame.questionRpcId}`)) this.pendingRev++ + const item = this.pending.get(`q:${frame.questionRpcId}`) + if (item !== undefined) this.settle(item) this.notifier.markDirty() return } @@ -295,6 +296,19 @@ export class Session implements ObservableSnapshot { // ---- 私有 ---- + /** Requested-frame arrival: the wait enters the pending map under its own key. */ + private mint(wait: PendingInteraction): void { + this.pending.set(wait.key, wait) + this.pendingRev++ + } + + /** Authoritative resolved-frame settlement: mark, then drop from the pending map. */ + private settle(wait: PendingInteraction): void { + wait.markSettled() + this.pending.delete(wait.key) + this.pendingRev++ + } + /** @param generation - openGeneration at launch; every await re-checks it and a stale pass * drops all writes (resync superseded this open — its outcome belongs to a dead connection). */ private async doOpen(generation: number): Promise { diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index 700026df52..0930787e0a 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -66,6 +66,10 @@ interface ErasedRegisterOptions { id?: string order?: number label?: string + /** Chain-slot routing selector (pure; the core validates presence for chain targets). */ + select?: (owner: never) => unknown + /** Chain-slot explicit ordering override (ascending; registration order otherwise). */ + priority?: number registrant?: string } diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index c13ef09fcb..25f12c2b70 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -2,7 +2,7 @@ // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { - HostFrame, IApiClient, MuxFrame, RpcError, RpcRequest, RpcResponse, SessionId, + ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, } from '@deepseek-ai/dsh-client-connection/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' @@ -93,8 +93,10 @@ export class FakeApiClient implements IApiClient { host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.hostConns, signal, onOpen), } - respond(): Promise<{ accepted: false; reason: 'not-pending' }> { - return Promise.resolve({ accepted: false, reason: 'not-pending' }) + onRespond: (message: ClientResponse) => Promise = () => Promise.resolve({ accepted: true }) + + respond(message: ClientResponse): Promise { + return this.record('respond', message, this.onRespond(message)) } /** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */ diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 87c80c2dce..d37ef6dbce 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -34,7 +34,7 @@ describe('instances', () => { manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } }) const session = manager.get(S1) - expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', approvalId: 'ap1' }]) + expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', payload: { approvalId: 'ap1' } }]) // Buffer cleared: a second instantiation of another id gets nothing. expect(manager.get(S2).getSnapshot().pending).toEqual([]) }) @@ -48,7 +48,7 @@ describe('instances', () => { } const pending = manager.get(S1).getSnapshot().pending expect(pending).toHaveLength(32) - expect(pending.map(p => p.rpcId)).toEqual(Array.from({ length: 32 }, (_, i) => `q${i + 8}`)) // oldest 8 dropped + expect(pending.map(p => p.key)).toEqual(Array.from({ length: 32 }, (_, i) => `q:q${i + 8}`)) // oldest 8 dropped // Removed session: buffered frames must not replay on a future instantiation. manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } }) manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } }) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 149f73c1fc..b980a674fe 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -251,6 +251,36 @@ describe('pending interactions', () => { session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq' as never, outcome: 'answered' }) expect(session.getSnapshot().pending).toEqual([]) }) + + it('mints waits whose respond() backfills the requested rpcId into the client-response envelope', async () => { + const { api, session } = makeSession() + session.handleMuxEnvelope('rq-answer' as never, { type: 'question/requested', sessionId: SID, questions: [] }) + const wait = session.getSnapshot().pending[0]! + expect(wait).toMatchObject({ kind: 'question', key: 'q:rq-answer', sessionId: SID, payload: { questions: [] } }) + const receipt = await wait.respond({ + ok: true, + value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } }, + }) + expect(receipt).toEqual({ accepted: true }) + expect(api.callsOf('respond')).toEqual([{ + type: 'client-response', rpcId: 'rq-answer', + result: { + ok: true, + value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } }, + }, + }]) + }) + + it('settles the wait on the authoritative resolved frame: respond() then throws synchronously', async () => { + const { api, session } = makeSession() + session.handleMuxEnvelope('rq1' as never, { type: 'question/requested', sessionId: SID, questions: [] }) + const wait = session.getSnapshot().pending[0]! + session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq1' as never, outcome: 'answered' }) + expect(session.getSnapshot().pending).toEqual([]) + expect(() => wait.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } })) + .toThrow('already settled') + expect(api.callsOf('respond')).toEqual([]) + }) }) describe('remaining branches', () => { @@ -355,7 +385,7 @@ describe('remaining branches', () => { session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap2' as never, toolName: 'rm', callId: 'c1' as never, reason: '危险', }) - expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', callId: 'c1', reason: '危险' }) + expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', payload: { callId: 'c1', reason: '危险' } }) session.handleMuxEnvelope('rx' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never }) session.handleMuxEnvelope('rx2' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never }) session.handleMuxEnvelope('ry2' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'never-was' as never, outcome: 'cancelled' }) @@ -568,6 +598,22 @@ describe('resync', () => { expect(cold.api.calls).toEqual([]) // never opened: no traffic }) + it('re-mints a replayed requested frame as a fresh wait with the same key (old reference superseded)', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] }) + const before = session.getSnapshot().pending[0]! + await session.resync() + session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] }) + const after = session.getSnapshot().pending[0]! + expect(after).not.toBe(before) + expect(after.key).toBe(before.key) + // Superseded ≠ settled: an in-flight respond on the stale reference still reaches the host. + await before.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } }) + expect(api.callsOf('respond')).toMatchObject([{ rpcId: 'rq-replay' }]) + }) + it('drops a stale in-flight open superseded by resync (generation guard)', async () => { const { api, session } = makeSession() const stale = deferred>>() diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 36917d48c1..8c4a6dc6a1 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -69,7 +69,12 @@ export function apply(ctx: Context): void { // ConversationRoot is the only component authorized to render the ring. slots.register({ name: 'conversation', - children: { 'conversation.view': { kind: 'list', scope: 'session' } }, + // The composer chain rides the same declaration table: takeover plugins + // register selector-routed replacements of the InputBar. + children: { + 'conversation.view': { kind: 'list', scope: 'session' }, + 'conversation.composer': { kind: 'chain', scope: 'session' }, + }, store: chatStore, inject: (sessionId: SessionId, actions: BoundActions): ConversationInjected => { // History pull is NOT triggered here: the runtime sessions service opens diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index c68cf98571..8023acddde 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -254,7 +254,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl ))} )} - {pending.map((item) => )} + {pending.map((item) => )} diff --git a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx index 56b886c9ad..b6825aed9a 100644 --- a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx @@ -16,13 +16,13 @@ export const PendingCard = memo(function PendingCard({ item }: PendingCardProps)
{item.kind === 'approval' ? ( <> -
等待审批:{item.toolName}
- {item.reason !== undefined &&
{item.reason}
} +
等待审批:{item.payload.toolName}
+ {item.payload.reason !== undefined &&
{item.payload.reason}
} ) : ( <> -
等待回答({item.questions.length} 题)
- +
等待回答({item.payload.questions.length} 题)
+ )}
请在原客户端处理(web 端作答后续里程碑提供)
diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index eabe747f8f..baa26683ec 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -11,7 +11,7 @@ * here. */ import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' import type { createChatStore } from '../stores.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' @@ -33,6 +33,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * `fallback` for unregistered tools. */ 'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps } + /** + * The composer takeover chain: entries are selector-routed replacements + * of the default InputBar. Declared by this package's 'conversation' + * entry; the owner dispatches the {@link ComposerChainProps} currency and + * routing lives in entry selectors — new takeover kinds register with + * zero owner changes. + */ + 'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps } } } @@ -107,9 +115,22 @@ export interface ConversationInjected { open(id: SessionId): void } -/** Full conversation-slot component props: runtime share & view-slot render share & store share & injected share. */ +/** + * Composer chain currency: what ConversationRoot dispatches at its + * renderSlotChain site. The owner declares the currency only — never a + * per-entry contract; takeover packages narrow it in their own selectors + * (`interactions.find(i => i.kind === ...)`), so new takeover kinds register + * with zero owner changes. + */ +export interface ComposerChainProps { + /** The session's live pending waits, in arrival order (snapshot reference). */ + interactions: readonly PendingInteraction[] +} + +/** Full conversation-slot component props: runtime & child-render (view ring + composer chain) & store & injected shares. */ export type ConversationSlotProps = - PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view'> & PropsStore & ConversationInjected + PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'> + & PropsStore & ConversationInjected /** * Injected share of the chat view entry: the two callbacks whose targets live diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 92110caa9e..59346a557b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -3,7 +3,8 @@ // props: the framework standard kit (useSession/sessionId/useSessions), the // declared chat store's useStore/actions, the injected business face, and the // renderSlot share for the declared 'conversation.view' child slot (views are -// slot entries; the active one renders via the list `only` filter). +// slot entries; the active one renders via the list `only` filter) plus the +// renderSlotChain share for the 'conversation.composer' takeover chain. // 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). @@ -36,7 +37,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session } export function ConversationRoot({ - sessionId, useSession, useSessions, useStore, actions, renderSlot, + sessionId, useSession, useSessions, useStore, actions, renderSlot, renderSlotChain, views, send, stop, open, }: ConversationRootProps) { useSyncExternalStore(views.subscribe, views.version) @@ -52,11 +53,27 @@ export function ConversationRoot({ const removed = useSession(s => s.removed) const promptError = useSession(s => s.promptError) const turns = useSession(s => countTurns(s)) + const pending = useSession(s => s.pending) const error: InputBarError | null = promptError === null ? null : { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` } + // The default composer doubles as the chain's all-decline fallback: a + // pending wait with no registered takeover must still leave the input usable. + const composerBar = ( + { send(draft, mode) }} + onStop={stop} + /> + ) + return (
@@ -106,16 +123,7 @@ export function ConversationRoot({ {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
- { send(draft, mode) }} - onStop={stop} - /> + {renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })}
) } diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index a2ce22e4d0..be50356185 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -4,9 +4,11 @@ // single-line reasoning. (Tool-row dispatch tails live with the keyed-slot // machinery specs since the tool ring dissolved into renderSlot.) -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' -import type { RpcId } from '@deepseek-ai/dsh-client-connection/client' +import { RpcId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { MessageItem } from '../src/client/chat/MessageItem.tsx' import { PendingCard } from '../src/client/chat/PendingCard.tsx' @@ -44,7 +46,7 @@ describe('MessageItem arms', () => { describe('small branch tails', () => { it('PendingCard approval reason renders when present', () => { const view = render( - , + ['payload'], vi.fn())} />, ) expect(view.getByText('careful')).toBeTruthy() }) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 45c3b3f6ca..90c2f3090c 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -10,7 +10,8 @@ import type { AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client' +import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' @@ -342,7 +343,8 @@ describe('ChatView', () => { it('pending interactions render placeholder cards', () => { const h = makeHarness({ - pending: [{ kind: 'approval', rpcId: 'r1' as never, approvalId: 'ap1', toolName: 'bash' }], + pending: [new PendingWait('approval', RpcId('r1'), SID, + { approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())], }) const view = render() expect(view.getByText(/等待审批/)).toBeTruthy() diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 856b6c452c..7f98cdd6ed 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -8,7 +8,8 @@ import { cleanup, render } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' -import type { RpcId } from '@deepseek-ai/dsh-client-connection/client' +import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' +import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as nodeApply } from '../src/index.ts' import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' @@ -34,7 +35,7 @@ describe('tails', () => { it('PendingCard renders the question arm with its count', () => { const view = render( - , + ['payload'], vi.fn())} />, ) expect(view.getByText(/等待回答(2 题)/)).toBeTruthy() }) diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index cc101d1402..b91fe229c3 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -21,6 +21,9 @@ import { EmptyState } from '../src/client/skeleton/EmptyState.tsx' afterEach(cleanup) const SID = 's1' as SessionId +/** Fallback-only chain stub (no takeover registered in these benches). */ +const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] = + (_key, _owner, opts) => opts?.fallback ?? null function snapshotBase(): ConversationSnapshot { return { @@ -73,6 +76,7 @@ describe('ConversationRoot branches', () => { useStore={hookOf(chat)} actions={chat.actions} renderSlot={stubRenderSlot} + renderSlotChain={fallbackRenderSlotChain} SessionProvider={SessionProviderStub} views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }} send={vi.fn()} @@ -131,6 +135,7 @@ describe('ConversationRoot branches', () => { useStore={hookOf(chat)} actions={chat.actions} renderSlot={stubRenderSlot} + renderSlotChain={fallbackRenderSlotChain} SessionProvider={SessionProviderStub} views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }} send={vi.fn()} diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 41480dc311..a4243c8cb7 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -13,7 +13,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { UseSession } from '@deepseek-ai/dsh-client-web-react' -import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationSnapshot, PendingInteraction, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client' +import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' +import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx' // Export discipline: packages/client/AGENTS.md. @@ -36,11 +38,12 @@ interface FakeSnapshot { running: boolean removed: boolean promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null + pending: readonly PendingInteraction[] } function fakeSession(init: Partial = {}) { const store = createSnapshotStore({ - nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init, + nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init, }) return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession } } @@ -99,8 +102,11 @@ describe('EmptyState', () => { }) describe('ConversationRoot', () => { - function bench(tabs: ViewTab[], activeView?: string) { - const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }] }) + function bench( + tabs: ViewTab[], activeView?: string, init: Partial = {}, + renderSlotChain?: ConversationRootProps['renderSlotChain'], + ) { + const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init }) const { useSessions } = fakeSessions([ { id: 'root', title: 'proj' }, { id: 's1', title: 'child', parentId: 'root' }, @@ -124,6 +130,7 @@ describe('ConversationRoot', () => { useStore={bindSnapshotSelector(chat)} actions={chat.actions} renderSlot={renderSlot as unknown as ConversationRootProps['renderSlot']} + renderSlotChain={renderSlotChain ?? ((_key, _owner, opts) => opts?.fallback ?? null)} SessionProvider={SessionProviderStub} views={{ list: () => tabs, @@ -179,6 +186,30 @@ describe('ConversationRoot', () => { fireEvent.keyDown(box, { key: 'Enter' }) expect(send).toHaveBeenCalledWith('hi', 'queue') }) + + it('dispatches the pending list to the composer chain; all-decline falls back to InputBar', () => { + const wait = new PendingWait('question', RpcId('rq'), sid('s1'), + { questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn()) + // A matching entry takes the composer over. + const renderSlotChain = vi.fn(() =>
question takeover
) as unknown as ConversationRootProps['renderSlotChain'] + bench([tab('chat', 'Chat')], undefined, { pending: [wait] }, renderSlotChain) + expect(screen.getByText('question takeover')).toBeTruthy() + expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull() + // The owner dispatches the raw pending list (chain currency); routing + // lives in entry selectors, not here. + expect(renderSlotChain).toHaveBeenCalledWith( + 'conversation.composer', + expect.objectContaining({ + interactions: expect.arrayContaining([expect.objectContaining({ key: 'q:rq' })]), + }), + expect.objectContaining({ fallback: expect.anything() }), + ) + cleanup() + // Zero registered entries (default all-decline stub): the fallback IS the + // default InputBar — behavior equals the pre-chain composer. + bench([tab('chat', 'Chat')], undefined, { pending: [wait] }) + expect(screen.getByPlaceholderText(/输入消息/)).toBeTruthy() + }) }) describe('DetailsPanel', () => { diff --git a/packages/client/ui-slots/README.md b/packages/client/ui-slots/README.md index e7adb5a79b..16d6380639 100644 --- a/packages/client/ui-slots/README.md +++ b/packages/client/ui-slots/README.md @@ -11,11 +11,13 @@ One `register({ name, children?, store?, inject?, ...kind }, Component)` call co | store | `PropsStore` | the declared handle: `useStore` selector hook + draft-stripped `actions` | | business | `I` | inferred from the `inject` factory's return | +Chain-kind slots invert keyed routing — entries self-nominate instead of the dispatch site picking an `entryKey`: each registration carries a pure `ChainSelect` selector (plus optional ascending `priority`, ties in registration order), the first non-null return elects its entry and becomes the component's `matched` prop, and all-null falls to the owner's `renderSlotChain` fallback (`ChainRenderOpts`). + The standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`) are declared empty here and merged by the runtime package (same declare-merge pattern as SlotMap keys). Inject factory parameters derive from the declaration (`InjectParams`): session slots get `sessionId`, a declared store appends baked `actions`, nothing else — data access lives in the apply closure's ctx. The store family (`defineStore` spec in / `StoreHandle` out) types the store seat: `init` infers the state schema, `actions` is the complete draft-transform write set, `BakedActions` strips the draft parameter into the callbacks components and inject factories receive. The `defineStore` value implementation lives in the runtime package (the engine's home) and satisfies the `DefineStore` contract exported here. Engine products and the renderer host contract carry bare snapshot sources (`getSnapshot`/`subscribe`), never React hooks — hook binding is the render machinery's side of the seam; only the props-contract hook type (`SnapshotSelectorHook`) lives here. -`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot. +`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes, a chain registration without `select` — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot. ## Model Experience diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index e08f884f44..809ba21d5a 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -22,8 +22,8 @@ export * from './renderer.ts' /** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */ export interface SlotMap {} -/** Slot cardinality: single occupant, ordered list, or key-dispatched. */ -export type SlotKind = 'single' | 'list' | 'keyed' +/** Slot cardinality: single occupant, ordered list, key-dispatched, or selector-routed chain. */ +export type SlotKind = 'single' | 'list' | 'keyed' | 'chain' /** Slot data context: root (no session) or session-bound. */ export type SlotScope = 'root' | 'session' @@ -98,6 +98,34 @@ export type PropsRuntime = /** renderSlot dispatch options: keyed dispatch key, list filtering, empty fallback. */ export interface RenderOpts { entryKey?: string; only?: string; fallback?: ReactNode } +/** renderSlotChain dispatch options: the owner's fallback body, rendered when every entry's selector declines. */ +export interface ChainRenderOpts { fallback?: ReactNode } + +/** + * Chain-entry selector: the routing decision of one chain contribution. + * Runs at render time in chain order (ascending `priority`, default 0, lower + * tries first; ties keep registration = assembly order); the first non-null + * return elects its entry + * and becomes the component's `matched` prop; `null` passes to the next + * entry; all-null falls to the owner's {@link ChainRenderOpts} fallback. + * MUST be pure — a function of the owner props only, no external mutable + * reads, no side effects (the decline decision lives here, never in a + * mounted component probing its own props). + */ +export type ChainSelect = (owner: O) => M | null + +/** Keys of a slot-key union whose SlotMap entry is chain-kind (renderSlotChain's dispatch domain). */ +export type ChainKeysOf = + S extends unknown ? (SlotMap[S]['kind'] extends 'chain' ? S : never) : never + +/** + * Chain matched share: a chain-slot component receives its selector's + * non-null result as the framework-injected `matched` prop; other kinds add + * nothing to the composed constraint. + */ +export type MatchedShare = + E['kind'] extends 'chain' ? { matched: M } : object + /** * Conversation-session selector hook alias for props contracts. Wide by * default at this dependency-inverted layer; the runtime narrows at its @@ -135,15 +163,27 @@ export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode */ export type PropsRenderSlots = { /** - * Render a declared child slot. + * Render a declared non-chain child slot (chain keys dispatch through + * `renderSlotChain` — their routing lives in entry selectors). * @param key - declared child key. * @param owner - owner props share for that key (decided at the render site). * @param opts - kind dispatch options. * @returns rendered node(s). */ - renderSlot: (key: K, owner: OwnerOf, opts?: RenderOpts) => ReactNode + renderSlot: >>(key: K, owner: OwnerOf, opts?: RenderOpts) => ReactNode readonly __renders?: ((key: S) => void) | undefined -} & ('session' extends ScopeOf +} & ([ChainKeysOf] extends [never] ? object : { + /** + * Render a declared chain child slot: entry selectors run in chain order + * over `owner`; the first non-null match renders its component with the + * selector result injected as `matched`; all-null renders `opts.fallback`. + * @param key - declared chain child key. + * @param owner - owner props share (the selectors' routing input). + * @param opts - fallback body for the all-null case. + * @returns rendered node(s). + */ + renderSlotChain: >(key: K, owner: OwnerOf, opts?: ChainRenderOpts) => ReactNode +}) & ('session' extends ScopeOf // The SessionProvider seat rides the same source as renderSlot: declaring // a session-scope child is what makes a session area exist, so the seat // derives from the children key set's scopes (renderer injects the value). @@ -168,7 +208,8 @@ export type ComposedProps< S extends keyof SlotMap & string, H, I extends object, -> = PropsRuntime & PropsRenderSlots & PropsStore & I + M = never, +> = PropsRuntime & PropsRenderSlots & PropsStore & I & MatchedShare /** * Inject factory parameter list, derived from the registration's declaration: @@ -182,27 +223,35 @@ export type InjectParams = ? ([H] extends [StoreDecl] ? [sessionId: SessionIdOf, actions: BoundActions>] : [sessionId: SessionIdOf]) : ([H] extends [StoreDecl] ? [actions: BoundActions>] : []) -/** Kind shape fields carried in register options (keyed dispatch key; list id/order/label). */ -export type KindOptions = +/** Kind shape fields carried in register options (keyed dispatch key; list id/order/label; chain select/priority). */ +export type KindOptions = E['kind'] extends 'keyed' ? { key: string } : E['kind'] extends 'list' ? { id: string; order?: number; label?: string } - : object + : E['kind'] extends 'chain' ? { + /** Routing selector, mandatory on chain entries; `M` (the component's `matched` prop) infers from its return. */ + select: ChainSelect + /** Explicit chain position (ascending, default 0, lower tries first); ties keep registration = assembly order. */ + priority?: number + } + : object /** * Compile-time presence check: an entry declaring children MUST consume - * `renderSlot` (declaring is claiming — an entry that does not render its - * children should not declare them). Evaluates to an unsatisfiable - * intersection member naming the declared keys when violated. + * `renderSlot` (or `renderSlotChain` when its only children are chain slots) + * — declaring is claiming; an entry that does not render its children should + * not declare them. Evaluates to an unsatisfiable intersection member naming + * the declared keys when violated. */ type RendersCheck = [keyof D & keyof SlotMap & string] extends [never] ? unknown : C extends (props: infer P) => ReactNode ? ('renderSlot' extends keyof P ? unknown - : { 'children declared but the component consumes no renderSlot': keyof D & keyof SlotMap & string }) + : 'renderSlotChain' extends keyof P ? unknown + : { 'children declared but the component consumes no renderSlot': keyof D & keyof SlotMap & string }) : unknown /** Common register options share (see {@link SlotCore.register} for semantics). */ -type BaseOptions = { +type BaseOptions = { /** Target slot key (the entry contributes INTO this slot). */ name: K /** Child-slot declaration + render authorization + runtime spec, in one table. */ @@ -211,7 +260,7 @@ type BaseOptions = store?: H /** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */ registrant?: string -} & KindOptions +} & KindOptions /** * One stored registration, as recorded by the core and read by the render @@ -220,7 +269,9 @@ type BaseOptions = */ export interface StoredEntry { component: unknown - options: { key?: string; id?: string; order?: number; label?: string } + options: { key?: string; id?: string; order?: number; label?: string; priority?: number } + /** Chain routing selector (type-erased like `inject`; present exactly on chain-slot entries). */ + select?: ((owner: never) => unknown) | undefined /** Registrant business face; positional params derive from the declaration (sessionId?, actions?). */ inject?: ((...args: never[]) => Record) | undefined /** Child-slot declaration table (declaration + authorization + runtime spec in one). */ @@ -243,6 +294,8 @@ interface ErasedOptions { id?: string | undefined order?: number | undefined label?: string | undefined + select?: ((owner: never) => unknown) | undefined + priority?: number | undefined children?: Record> | undefined store?: StoreDecl | undefined /* eslint-disable-next-line @typescript-eslint/no-explicit-any -- @@ -308,7 +361,8 @@ export class SlotCore { * names the first declarer); mounting one shared store handle under slots * of different scopes throws. Kind constraints: single — duplicate * registration throws; keyed — missing/duplicate `key` throws; list — - * missing/duplicate `id` throws. + * missing/duplicate `id` throws; chain — missing `select` throws (the + * selector is the entry's routing seat, see {@link ChainSelect}). * * Lifecycle: the disposer removes the contribution AND collapses every * declared child slot (child entries clear recursively; their stale @@ -326,11 +380,12 @@ export class SlotCore { K extends keyof SlotMap & string, const D extends ChildrenDecl = Record, H extends StoreDecl | undefined = undefined, + M = never, C extends SlotComponent = SlotComponent, >( - options: BaseOptions & { inject?: undefined }, + options: BaseOptions & { inject?: undefined }, component: C - & SlotComponent & keyof SlotMap & string, HandleOf>, object>> + & SlotComponent & keyof SlotMap & string, HandleOf>, object, NoInfer>> & RendersCheck, ): () => void /** @@ -348,11 +403,12 @@ export class SlotCore { I extends object, const D extends ChildrenDecl = Record, H extends StoreDecl | undefined = undefined, + M = never, C extends SlotComponent = SlotComponent, >( - options: BaseOptions & { inject: (...args: InjectParams) => I }, + options: BaseOptions & { inject: (...args: InjectParams) => I }, component: C - & SlotComponent & keyof SlotMap & string, HandleOf>, I>> + & SlotComponent & keyof SlotMap & string, HandleOf>, I, NoInfer>> & RendersCheck, ): () => void register(options: ErasedOptions, component: unknown): () => void { @@ -379,6 +435,9 @@ export class SlotCore { throw new Error(`list slot "${options.name}" already has an entry with id "${options.id}"`) } break + case 'chain': + if (options.select === undefined) throw new Error(`chain slot "${options.name}" requires options.select`) + break } if (options.children) { for (const childKey of Object.keys(options.children)) { @@ -407,15 +466,19 @@ export class SlotCore { ...(options.id !== undefined ? { id: options.id } : {}), ...(options.order !== undefined ? { order: options.order } : {}), ...(options.label !== undefined ? { label: options.label } : {}), + ...(options.priority !== undefined ? { priority: options.priority } : {}), }, + ...(options.select !== undefined ? { select: options.select } : {}), ...(options.inject !== undefined ? { inject: options.inject } : {}), ...(options.children !== undefined ? { children: options.children } : {}), ...(options.store !== undefined ? { store: options.store } : {}), ...(options.registrant !== undefined ? { registrant: options.registrant } : {}), } const next = [...rec.entries, entry] - // Stable sort: order ascending, ties keep registration sequence. + // Stable sorts: ascending, ties keep registration sequence (list rides + // `order`, chain rides `priority` — lower priority tries first). if (spec.kind === 'list') next.sort((a, b) => (a.options.order ?? 0) - (b.options.order ?? 0)) + if (spec.kind === 'chain') next.sort((a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0)) rec.entries = next this.markDirty(options.name, rec) if (options.children) { diff --git a/packages/client/ui-slots/tests/core.spec.ts b/packages/client/ui-slots/tests/core.spec.ts index 87c96177d8..8ed6b56498 100644 --- a/packages/client/ui-slots/tests/core.spec.ts +++ b/packages/client/ui-slots/tests/core.spec.ts @@ -13,6 +13,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'test.session': { kind: 'single'; scope: 'session' } 'test.list': { kind: 'list'; scope: 'root' } 'test.keyed': { kind: 'keyed'; scope: 'session' } + 'test.chain': { kind: 'chain'; scope: 'session'; owner: { tags: string[] } } 'test.grandchild': { kind: 'single'; scope: 'root' } } } @@ -39,6 +40,7 @@ function mountFrame(core: SlotCore) { 'test.session': { kind: 'single', scope: 'session' }, 'test.list': { kind: 'list', scope: 'root' }, 'test.keyed': { kind: 'keyed', scope: 'session' }, + 'test.chain': { kind: 'chain', scope: 'session' }, }, // Type-level renderSlot presence is proven by the type-chain spec; erasing // here keeps runtime fixtures terse. @@ -148,6 +150,31 @@ describe('kind semantics', () => { expect(core.entries('test.list').map(e => e.options.id)).toEqual(['a', 'b', 'c']) }) + it('chain: missing select throws; select and priority land on the stored entry', () => { + const core = new SlotCore() + mountFrame(core) + // Statically rejected (KindOptions); runtime guard stays for dynamic callers. + // @ts-expect-error chain registration requires options.select + expect(() => core.register({ name: 'test.chain' }, Comp)).toThrow('requires options.select') + const select = ({ tags }: { tags: string[] }) => tags[0] ?? null + core.register({ name: 'test.chain', select, priority: 5 }, Comp as never) + const entry = core.entries('test.chain')[0]! + expect(entry.select).toBe(select) + expect(entry.options.priority).toBe(5) + }) + + it('chain: entries sort by priority ascending, ties keep registration order', () => { + const core = new SlotCore() + mountFrame(core) + const sel = () => null + core.register({ name: 'test.chain', select: sel, priority: 10, registrant: 'late' }, Comp as never) + core.register({ name: 'test.chain', select: sel, registrant: 'default-a' }, Comp as never) + core.register({ name: 'test.chain', select: sel, registrant: 'default-b' }, Comp as never) + core.register({ name: 'test.chain', select: sel, priority: -1, registrant: 'first' }, Comp as never) + expect(core.entries('test.chain').map(e => e.registrant)) + .toEqual(['first', 'default-a', 'default-b', 'late']) + }) + it('single: second registration throws, disposer frees the seat', () => { const core = new SlotCore() mountFrame(core) @@ -294,7 +321,7 @@ describe('subscription surface', () => { const off = core.onMutate(key => keys.push(key)) mountFrame(core) // Contribution first, then each declared child key. - expect(keys).toEqual(['root', 'test.single', 'test.session', 'test.list', 'test.keyed']) + expect(keys).toEqual(['root', 'test.single', 'test.session', 'test.list', 'test.keyed', 'test.chain']) keys.length = 0 core.register({ name: 'test.list', id: 'a' }, Comp) expect(keys).toEqual(['test.list']) diff --git a/packages/client/ui-slots/tests/type-chain.spec.tsx b/packages/client/ui-slots/tests/type-chain.spec.tsx index d45dfee0a3..8469697557 100644 --- a/packages/client/ui-slots/tests/type-chain.spec.tsx +++ b/packages/client/ui-slots/tests/type-chain.spec.tsx @@ -20,9 +20,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'chain.side': { kind: 'single'; scope: 'root'; owner: { collapsed: boolean; width: number } } 'chain.conv': { kind: 'single'; scope: 'session' } 'chain.tools': { kind: 'keyed'; scope: 'session' } + 'chain.takeover': { kind: 'chain'; scope: 'session'; owner: { items: readonly Item[] } } } } +/** Chain-currency fixture: the owner share carries a union the selectors narrow. */ +interface Item { kind: 'q' | 'a'; id: string } + declare const defineStore: DefineStore /** Factory form (exclusive seat): module-level export, never a handle. */ @@ -68,6 +72,9 @@ declare function NoDecl(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'c declare function Blind(props: PropsRuntime<'chain.frame'>): ReactNode declare function WrongStore(props: PropsRuntime<'chain.conv'> & PropsStore>): ReactNode declare function Needs(props: PropsRuntime<'chain.conv'> & { send: (t: string) => void }): ReactNode +declare function Takeover(props: PropsRuntime<'chain.takeover'> & { matched: Item }): ReactNode +declare function WideTakeover(props: PropsRuntime<'chain.takeover'> & { matched: Item | string }): ReactNode +declare function NarrowTakeover(props: PropsRuntime<'chain.takeover'> & { matched: { kind: 'q'; id: string; extra: number } }): ReactNode describe('terminal-design type chain', () => { it('holds the positive chain and the compile-time negatives', () => { @@ -115,6 +122,28 @@ describe('terminal-design type chain', () => { // Keyed registration carries key. core.register({ name: 'chain.tools', key: 'bash' }, Tool) + // Chain registration: select is mandatory, M infers from its return, + // matched joins the component constraint; priority is the explicit + // chain position. + core.register({ + name: 'chain.takeover', + select: ({ items }) => items.find((i) => i.kind === 'q') ?? null, + priority: 1, + }, Takeover) + + // A component accepting a wider matched than the selector supplies + // checks through parameter contravariance. + core.register({ + name: 'chain.takeover', + select: ({ items }) => items.find((i) => i.kind === 'q') ?? null, + }, WideTakeover) + + // renderSlotChain share: chain keys dispatch with the fallback bag; + // non-chain keys stay on renderSlot. + const chainSlots: PropsRenderSlots<'chain.takeover' | 'chain.conv'> = null as never + chainSlots.renderSlotChain('chain.takeover', { items: [] }, { fallback: null }) + chainSlots.renderSlot('chain.conv', {}) + // ── negatives ────────────────────────────────────────────────── // children spec must match the SlotMap entry. core.register({ @@ -156,6 +185,34 @@ describe('terminal-design type chain', () => { // @ts-expect-error keyed registration requires options.key core.register({ name: 'chain.tools' }, Tool) + // chain registration without select. + // @ts-expect-error chain registration requires options.select + core.register({ name: 'chain.takeover' }, Takeover) + + // Drifted chain component: demands a matched shape the selector cannot + // supply (NoInfer pins M to the select return — the component position + // must not widen it). + // @ts-expect-error component matched prop drifts from the select return + core.register({ + name: 'chain.takeover', + select: ({ items }: { items: readonly Item[] }) => items.find((i) => i.kind === 'q') ?? null, + }, NarrowTakeover) + + // select must return M | null, not undefined (find() must be coalesced). + // @ts-expect-error select may not return undefined + core.register({ + name: 'chain.takeover', + select: ({ items }: { items: readonly Item[] }) => items.find((i) => i.kind === 'q'), + }, Takeover) + + // Chain keys are not renderSlot-dispatchable (and vice versa). + // @ts-expect-error chain keys dispatch through renderSlotChain only + chainSlots.renderSlot('chain.takeover', { items: [] }) + // @ts-expect-error non-chain keys have no renderSlotChain dispatch + chainSlots.renderSlotChain('chain.conv', {}) + // @ts-expect-error a children set without chain keys provides no renderSlotChain + fp.renderSlotChain + // renderSlot owner share typed at the call site. // @ts-expect-error owner shape mismatch (width missing) fp.renderSlot('chain.side', { collapsed: false }) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index b62e3efcfc..ab75da20b2 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -28,6 +28,9 @@ import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/ import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' const SID = 's1' as SessionId +/** Fallback-only chain stub (no composer takeover in these benches). */ +const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] = + (_key, _owner, opts) => opts?.fallback ?? null afterEach(cleanup) // The chat store persists under its declared key; clear so one case's active @@ -120,6 +123,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES useStore={bindSnapshotSelector(chat)} actions={chat.actions} renderSlot={renderSlot} + renderSlotChain={fallbackRenderSlotChain} SessionProvider={SessionProviderStub} views={{ list: () => tabsOf(slots), diff --git a/packages/client/web-react/README.md b/packages/client/web-react/README.md index fcc67a8ec2..704e09d96f 100644 --- a/packages/client/web-react/README.md +++ b/packages/client/web-react/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-web-react -Shell-side React glue for the slot terminal design: createSlotRenderer (the SlotRenderer implementation the shell installs into the runtime SlotsService), SessionProvider (framework-wired render prop, also injected as a standard seat to entries declaring session-scope children), bindSnapshotSelector (the one hook constructor — hosts and engines traffic in bare observable sources; every hook binds here, cached per source), useInvoke. The snapshot-store engine and defineStore live in runtime (store relocation); business plugins depend on ui-slots types only, never on this package. +Shell-side React glue for the slot terminal design: createSlotRenderer (the SlotRenderer implementation the shell installs into the runtime SlotsService), SessionProvider (framework-wired render prop, also injected as a standard seat to entries declaring session-scope children), bindSnapshotSelector (the one hook constructor — hosts and engines traffic in bare observable sources; every hook binds here, cached per source), useInvoke. Chain-slot outlets run the registered selectors in chain order at render time and mount only the elected entry, its select return joining the props as `matched`; the `renderSlotChain` binding is per-entry cached like `renderSlot`. The snapshot-store engine and defineStore live in runtime (store relocation); business plugins depend on ui-slots types only, never on this package. ## Model Experience diff --git a/packages/client/web-react/src/index.ts b/packages/client/web-react/src/index.ts index cd3189b891..b5c975305a 100644 --- a/packages/client/web-react/src/index.ts +++ b/packages/client/web-react/src/index.ts @@ -22,7 +22,7 @@ export type UseSession = SnapshotSelectorHook /** Owner-facing renderSlot binding shape (typed narrowing lands on the wave-1 props seam). */ type RenderSlotBinding = (key: string, owner: object, opts?: RenderOpts) => ReactNode +/** Owner-facing renderSlotChain binding shape (typed narrowing lands on the props seam). */ +type RenderSlotChainBinding = (key: string, owner: object, opts?: ChainRenderOpts) => ReactNode + /** * Per-entry renderSlot bindings. The binding is identity-stable per entry * (memoized components must not resubscribe on unrelated re-renders) and dies @@ -43,9 +49,13 @@ function boundRenderSlot(host: SlotRendererHost, entry: StoredEntry): RenderSlot throw new StaleAuthorizationError(`renderSlot('${key}') from a disposed registration`) } // Plain-JS backstop; typed callers are narrowed to the declared keys. - if (entry.children?.[key] === undefined) { + const declared = entry.children?.[key] + if (declared === undefined) { throw new SlotOwnershipError(`slot '${key}' is not declared by this entry's children`) } + if (declared.kind === 'chain') { + throw new SlotOwnershipError(`slot '${key}' is declared 'chain' — use renderSlotChain`) + } return } renderSlotCache.set(entry, binding) @@ -53,6 +63,35 @@ function boundRenderSlot(host: SlotRendererHost, entry: StoredEntry): RenderSlot return binding } +/** + * Per-entry renderSlotChain bindings: identity-stable per entry (same cache + * axis as renderSlot — a per-frame dispatch must not rebuild the binding) and + * dead with the entry. The chain-kind check is the plain-JS backstop twin of + * the declaration check; typed callers are narrowed to chain keys. + */ +const renderSlotChainCache = new WeakMap() + +function boundRenderSlotChain(host: SlotRendererHost, entry: StoredEntry): RenderSlotChainBinding { + let binding = renderSlotChainCache.get(entry) + if (!binding) { + binding = (key, owner, opts) => { + if (!host.isLive(entry)) { + throw new StaleAuthorizationError(`renderSlotChain('${key}') from a disposed registration`) + } + const declared = entry.children?.[key] + if (declared === undefined) { + throw new SlotOwnershipError(`slot '${key}' is not declared by this entry's children`) + } + if (declared.kind !== 'chain') { + throw new SlotOwnershipError(`slot '${key}' is declared '${declared.kind}', not 'chain' — use renderSlot`) + } + return + } + renderSlotChainCache.set(entry, binding) + } + return binding +} + /** * Inject results cache: root entries per entry, session entries per * (entry x session cell). WeakMap keys are entry/cell objects (both @@ -96,6 +135,26 @@ function cachedSessionInject(entry: StoredEntry, cell: SessionCell, actions: obj return props } +/** + * Entry-identity React keys for chain boundaries. A chain outlet renders ONE + * elected entry through an error boundary; without a key, a boundary that + * failed on entry A would survive a re-election and keep a healthy entry B + * blacked out. Keying by entry identity remounts the boundary fresh whenever + * the election changes (entries are identity-stable per registration, so the + * key is stable while the same entry stays elected). + */ +let nextEntryKey = 0 +const entryKeys = new WeakMap() + +function entryKeyOf(entry: StoredEntry): number { + let key = entryKeys.get(entry) + if (key === undefined) { + key = nextEntryKey++ + entryKeys.set(entry, key) + } + return key +} + /** * Per-entry isolation: one registrant crashing (component render or inject * factory) must not take down siblings. Assembly errors (missing providers) @@ -144,6 +203,11 @@ function standardKit(host: SlotRendererHost, entry: StoredEntry, cell: SessionCe } if (entry.children !== undefined) { kit['renderSlot'] = boundRenderSlot(host, entry) + // renderSlotChain rides the same declaration source: only entries whose + // children include a chain-kind slot receive the chain dispatch seat. + if (Object.values(entry.children).some((spec) => spec.kind === 'chain')) { + kit['renderSlotChain'] = boundRenderSlotChain(host, entry) + } // SessionProvider standard seat: entries declaring a session-scope child // render the session area, so the framework hands them the self-wired // provider (module-level component = stable reference; no value import). @@ -198,9 +262,9 @@ function SlotOutlet({ slotKey, ownerProps, opts }: { // The boundary must wrap the Entry ELEMENT, not live inside it: inject // factories and kit synthesis run in the Entry body and must land in the // per-entry fallback rather than escaping to the tree above. - const guarded = (entry: StoredEntry, key?: string | number) => ( + const guarded = (entry: StoredEntry, key?: string | number, owner: object = ownerProps) => ( - + ) @@ -214,6 +278,32 @@ function SlotOutlet({ slotKey, ownerProps, opts }: { if (!entry) return <>{opts?.fallback ?? null} return guarded(entry) } + if (spec.kind === 'chain') { + // Entries arrive priority-sorted from the ledger (the core orders at + // register, ties keep registration sequence). Selectors are pure + // functions of the owner props (register-face contract), so the routing + // pass runs per render with zero mount side effects: the first non-null + // election renders, decliners never mount. + for (const entry of entries) { + let matched: unknown + try { + // Chain entries always carry select (SlotCore register validation). + matched = (entry.select as (owner: object) => unknown)(ownerProps) + } catch (error) { + // A throwing selector is a registrant contract breach (select MUST be + // pure and total), but it runs before the entry's SlotErrorBoundary + // exists — uncontained it would black out the whole owner region. So + // it degrades to a decline: the chain and the fallback stay intact, + // and the breach is reported like a crashed entry. + console.error( + `chain selector crashed in '${slotKey}' (${entry.registrant ?? 'unknown registrant'}), treating as declined:`, + error) + continue + } + if (matched !== null) return guarded(entry, entryKeyOf(entry), { ...ownerProps, matched }) + } + return <>{opts?.fallback ?? null} + } // list: registration order refined by explicit order, optional id filter. const withListOptions = entries.map((entry) => ({ entry, diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx index 8cbbf21be4..aab243ab47 100644 --- a/packages/client/web-react/tests/scoped-slots.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots.spec.tsx @@ -13,13 +13,14 @@ import { act, render } from '@testing-library/react' import type { ReactNode } from 'react' import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' import { - createSlotRenderer, SessionProvider, SlotOwnershipError, + createSlotRenderer, SessionProvider, SlotOwnershipError, StaleAuthorizationError, type RenderOpts, type SessionCell, type SlotRendererHost, type StoreInstanceLike, } from '@deepseek-ai/dsh-client-web-react' type AnyProps = Record type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode +type RenderSlotChainFn = (key: string, owner: object, opts?: { fallback?: ReactNode }) => ReactNode type DeclaredSpec = SlotSpec /** Entry literal helper: fake entries default the mandatory options bag. */ const entryOf = (partial: Omit & { options?: StoredEntry['options'] }): StoredEntry => @@ -129,7 +130,13 @@ function makeHost() { declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) }, add: (key: string, partial: Omit & { options?: StoredEntry['options'] }) => { const entry = entryOf(partial) - entries.set(key, [...(entries.get(key) ?? []), entry]) + const next = [...(entries.get(key) ?? []), entry] + // Mirror the ledger contract: chain entries arrive priority-sorted + // (stable, ascending) — outlets iterate entries() order as-is. + if (specs.get(key)?.kind === 'chain') { + next.sort((a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0)) + } + entries.set(key, next) live.add(entry) bump(key) return () => { @@ -165,6 +172,29 @@ function mountRoot(h: Fake, children: Record, body: (rende const SINGLE_ROOT: DeclaredSpec = { kind: 'single', scope: 'root' } const SINGLE_SESSION: DeclaredSpec = { kind: 'single', scope: 'session' } +const CHAIN_ROOT: DeclaredSpec = { kind: 'chain', scope: 'root' } + +/** Chain entry literal: top-level select, priority in the options bag (the StoredEntry chain shape). */ +const chainEntryOf = (partial: { + component: unknown + select: (owner: object) => unknown + priority?: number +}): Omit & { options?: StoredEntry['options'] } => ({ + component: partial.component, + select: partial.select as StoredEntry['select'], + ...(partial.priority !== undefined ? { options: { priority: partial.priority } } : {}), +}) + +/** Mount a root entry whose component renders `body` with its kit renderSlotChain. */ +function mountChainRoot(h: Fake, children: Record, body: (renderSlotChain: RenderSlotChainFn) => ReactNode) { + const dispose = h.add('root', { + component: (props: { renderSlotChain: RenderSlotChainFn }) => <>{body(props.renderSlotChain)}, + children, + }) + const renderer = createSlotRenderer() + const view = render(<>{renderer.renderRoot(h.host, {})}) + return { view, dispose } +} describe('root outlet', () => { it('renders the root registration and fails loud when root is unregistered (boot order)', () => { @@ -262,6 +292,183 @@ describe('child outlets and the renderSlot binding', () => { }) }) +describe('chain outlets and the renderSlotChain binding', () => { + it('elects the first non-null selector in order, injects matched, and skips decliners without mounting them', () => { + const h = makeHost() + h.declare('k.chain', CHAIN_ROOT) + const declinerBody = vi.fn(() => never) + h.add('k.chain', chainEntryOf({ + component: declinerBody, + select: () => null, + })) + h.add('k.chain', chainEntryOf({ + component: ({ matched }: { matched?: { label: string } }) => {matched?.label}, + select: (owner) => ({ label: `hit:${(owner as { tag: string }).tag}` }), + })) + const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, + (renderSlotChain) => renderSlotChain('k.chain', { tag: 'T' })) + // The declining entry never mounts: the routing decision is select-layer only. + expect(view.container.textContent).toBe('hit:T') + expect(declinerBody).not.toHaveBeenCalled() + }) + + it('contains a throwing selector to its entry: reported, treated as declined, chain and fallback intact', () => { + const h = makeHost() + h.declare('k.chain', CHAIN_ROOT) + h.add('k.chain', chainEntryOf({ + component: () => never, + select: () => { throw new Error('selector boom') }, + })) + h.add('k.chain', chainEntryOf({ + component: ({ matched }: { matched?: string }) => {matched}, + select: (owner) => (owner as { pick?: string }).pick ?? null, + })) + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <> +
{renderSlotChain('k.chain', { pick: 'OK' })}
+ + ) + // The breach never escapes to the owner region: later entries still get + // tried, and an all-throw/all-null pass still lands on the fallback. + expect(view.container.querySelector('main')!.textContent).toBe('OK') + expect(view.container.querySelector('aside')!.textContent).toBe('fb') + expect(spy.mock.calls.some(([msg]) => String(msg).includes('chain selector crashed'))).toBe(true) + spy.mockRestore() + }) + + it('remounts the boundary on re-election: a failed entry does not black out its replacement', () => { + const h = makeHost() + h.declare('k.chain', CHAIN_ROOT) + h.add('k.chain', chainEntryOf({ + component: () => { throw new Error('entry A boom') }, + select: (owner) => (owner as { pick?: string }).pick === 'A' ? {} : null, + })) + h.add('k.chain', chainEntryOf({ + component: () => B-ok, + select: (owner) => (owner as { pick?: string }).pick === 'B' ? {} : null, + })) + let pick = 'A' + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, + (renderSlotChain) => renderSlotChain('k.chain', { pick })) + spy.mockRestore() + expect(view.container.querySelector('[data-slot-error]')).not.toBeNull() + // Re-elect entry B: the entry-keyed boundary remounts fresh instead of + // holding A's failed state over the healthy replacement. + pick = 'B' + act(() => { h.add('root', { component: () => null }) }) // root bump re-renders the dispatch site + expect(view.container.textContent).toBe('B-ok') + expect(view.container.querySelector('[data-slot-error]')).toBeNull() + }) + + it('falls to the owner fallback when every selector declines, and re-routes live', () => { + const h = makeHost() + h.declare('k.chain', CHAIN_ROOT) + h.add('k.chain', chainEntryOf({ + component: ({ matched }: { matched?: string }) => {matched}, + select: (owner) => (owner as { pick?: string }).pick ?? null, + })) + const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <> +
{renderSlotChain('k.chain', {}, { fallback: bar })}
+ + ) + // Same chain, two dispatch sites: all-null owner props fall back, matching ones elect. + expect(view.container.querySelector('main')!.textContent).toBe('bar') + expect(view.container.querySelector('aside')!.textContent).toBe('P') + }) + + it('renders the fallback for an empty chain and elects live once an entry registers', () => { + const h = makeHost() + h.declare('k.chain', CHAIN_ROOT) + const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, + (renderSlotChain) => renderSlotChain('k.chain', {}, { fallback: none })) + expect(view.container.textContent).toBe('none') + let dispose = () => {} + act(() => { + dispose = h.add('k.chain', chainEntryOf({ + component: () => IN, + select: () => ({}), + })) + }) + expect(view.container.textContent).toBe('IN') + act(() => { dispose() }) + expect(view.container.textContent).toBe('none') + }) + + it('orders the chain by ascending priority with registration sequence breaking ties', () => { + const h = makeHost() + h.declare('k.chain', CHAIN_ROOT) + // Registered first but priority 2: must yield to the later priority-1 entry. + h.add('k.chain', chainEntryOf({ + component: () => late, + select: () => ({}), + priority: 2, + })) + h.add('k.chain', chainEntryOf({ + component: () => early, + select: () => ({}), + priority: 1, + })) + // Tie pair at priority 1: registration order decides (early wins over tie). + h.add('k.chain', chainEntryOf({ + component: () => tie, + select: () => ({}), + priority: 1, + })) + const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, + (renderSlotChain) => renderSlotChain('k.chain', {})) + expect(view.container.textContent).toBe('early') + }) + + it('keeps the renderSlotChain binding identity-stable across re-renders', () => { + const h = makeHost() + h.declare('k.chain', CHAIN_ROOT) + const seen: RenderSlotChainFn[] = [] + mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => { + seen.push(renderSlotChain) + return renderSlotChain('k.chain', {}, { fallback: fb }) + }) + act(() => { h.add('root', { component: () => null }) }) // root bump re-renders the entry + expect(seen.length).toBeGreaterThan(1) + expect(seen.at(-1)).toBe(seen[0]) + }) + + it('backstops off-declaration keys, kind mismatches both ways, and disposed registrations', () => { + const h = makeHost() + h.declare('k.chain', CHAIN_ROOT) + h.declare('k.single', SINGLE_ROOT) + let chainFn: RenderSlotChainFn | undefined + let slotFn: RenderSlotFn | undefined + const dispose = h.add('root', { + component: (props: { renderSlot: RenderSlotFn; renderSlotChain: RenderSlotChainFn }) => { + slotFn = props.renderSlot + chainFn = props.renderSlotChain + return null + }, + children: { 'k.chain': CHAIN_ROOT, 'k.single': SINGLE_ROOT }, + }) + const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}) + expect(() => chainFn!('k.undeclared', {})).toThrow(SlotOwnershipError) + expect(() => chainFn!('k.single', {})).toThrow(SlotOwnershipError) // non-chain key via chain face + expect(() => slotFn!('k.chain', {})).toThrow(SlotOwnershipError) // chain key via plain face + view.unmount() + dispose() + expect(() => chainFn!('k.chain', {})).toThrow(StaleAuthorizationError) + }) + + it('withholds the renderSlotChain seat from entries declaring no chain child', () => { + const h = makeHost() + h.declare('k.single', SINGLE_ROOT) + const seen: AnyProps[] = [] + h.add('root', { + component: (props: AnyProps) => { seen.push(props); return null }, + children: { 'k.single': SINGLE_ROOT }, + }) + render(<>{createSlotRenderer().renderRoot(h.host, {})}) + expect(seen.at(-1)!['renderSlotChain']).toBeUndefined() + }) +}) + describe('standard-kit synthesis', () => { it('delivers a live useSessions hook to every slot component', () => { const h = makeHost()