diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md index 85c62b7e17..1ba3b94e8b 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md +++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md @@ -53,9 +53,9 @@ packages/ The package list had been enumerated in five places. The uniform depth-2 layout lets most of them be derived instead: -- `tsconfig.base.json` maps every package through a single `@deepseek-ai/dsh-*` `paths` wildcard listing one candidate per group, in place of per-package entries. Root `tsconfig.json` reuses that source map and carries the explicit project references that keep package/vendor typecheck boundaries intact. (One subtlety this introduced: a path candidate contains `/*/`, which a naive regex comment-stripper mistakes for a block comment — `scripts/doc-typecheck.ts` reads the JSONC config through TypeScript's parser rather than stripping comments by hand for exactly this reason.) +- `tsconfig.base.json` maps every package through a single `@deepseek-ai/dsh-*` `paths` wildcard listing one candidate per group, in place of per-package entries. The aggregate configs (`tsconfig.host.json`, `tsconfig.client.json`) reuse that source map and carry the explicit project references that keep package/vendor typecheck boundaries intact. (One subtlety this introduced: a path candidate contains `/*/`, which a naive regex comment-stripper mistakes for a block comment — `scripts/doc-typecheck.ts` reads the JSONC config through TypeScript's parser rather than stripping comments by hand for exactly this reason.) - `scripts/publint-all.ts` derives its list by reading the hierarchy (`packages//`), resolving the `TODO(package-inventory)`. -- `tsconfig.build.json`'s project `references` stay an explicit list — TypeScript project references have no wildcard form. Generating these from a manifest is left to a follow-up (see [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md)). +- The aggregates' project `references` stay explicit lists — TypeScript project references have no wildcard form. Generating these from a manifest is left to a follow-up (see [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md)). ### Guardrails added diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index d3e5608c22..c2f039b148 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.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-layering-and-rpc-protocol.md: ebe21a6060ec69ba9807ab9fbf9906ae24b07823 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 0c256b60ce44a8e16ec6edfba146c776c4ae2129 +2026-07-19-gui-layering-and-rpc-protocol.md: 65fb01f44698c61e6bf6958e332e1854fbb77fa9 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: e8b15789846ea124fb6a90f2afef437184d4348a diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index ebe21a6060..65fb01f446 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -53,7 +53,7 @@ Direction discipline (every rule auditable from package deps): - `webserver` does not depend on `runtime`: it provides a `{ fetch }`-shaped implementation — "webserver ← runtime" is a runtime injection relationship, not a package dependency. - Cross-package client imports use the `/client` subpath for plugin packages (a bare package name would inline a second runtime instance into a browser bundle; the tsdown purity gate rewrites or rejects it). -TypeScript checks in **two aggregate programs** (`tsconfig.json` = host side + tests, excluding `packages/client`; `tsconfig.client.json` = client packages and their tests): both sides merge the cordis `Context` interface under the same keys (`sessions`, `loader`) with different services, so one program would see both declaration merges and report a collision. Shared leaves (session/llm/tools/apiproxy…) build once and are referenced by both programs. +TypeScript checks in **two aggregate programs** referenced by a solution root (`tsconfig.json` = solution; `tsconfig.host.json` = host side + tests, excluding `packages/client`; `tsconfig.client.json` = client packages and their tests): both sides merge the cordis `Context` interface under the same keys (`sessions`, `loader`) with different services, so one program would see both declaration merges and report a collision. Shared leaves (session/llm/tools/apiproxy…) build once and are referenced by both programs ([topology](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md)). On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Node dependencies, browser-importable); wire messages unify under a **bidirectional model** — each logical message is shaped by "who initiates × request/response" (two axes, four cells, called the four quadrants below), decoupled from the physical channel; clients all inherit `AbstractApiClient` (protocol invariants live entirely in the base class, platform differences are just the `doFetch` transport aspect). diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index 0c256b60ce..e8b1578984 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -51,7 +51,7 @@ harness core packages ──────────────────┘ - `webserver` 不依赖 `runtime`:它提供 `{ fetch }` 特定实现 ——「webserver ← runtime」只是运行时注入关系,不是包依赖。 - client 侧跨包 import 插件包一律走 `/client` 子路径(裸包名会把第二份运行时实例内联进浏览器 bundle;tsdown 纯度门禁会改写或拒收)。 -TypeScript 以**两个聚合 program** 检查(`tsconfig.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用。 +TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.json` = solution;`tsconfig.host.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用([拓扑](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md))。 协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息由「谁发起 × request/response」定形(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。 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..1868da83b3 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: f21b840493b83c02d7abc3ba1c1bf90635166ec1 +2026-07-19-gui-web-client-architecture.zh.md: a50dc556cfc96b6d35feea6ef2b1aadae9f31c44 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..f21b840493 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 @@ -39,32 +39,17 @@ The loading chain, end to end: **The dual-instance ban**: a module-table package inlined into a plugin bundle would duplicate runtime identity (two React copies, two store registries — the root cause of an actual white-screen P0). The tsdown client preset enforces purity at build time: a bare-name import of a module-table package must resolve external (rewritten to its `/client` form where applicable), and any other workspace leak that is not an inline-safe wire/type layer fails the build (`packages/client/tsdown.client.ts`, pinned by `scripts/client-bundle-purity.spec.ts`). -Dev equals prod: plugins rebuild under `tsdown --watch`, refresh reloads the same chain; vite serves only the shell (`apps/web`). Type universes stay split at the aggregate level — the root `tsconfig.json` is the host program, `tsconfig.client.json` the client program, because both sides merge cordis `Context` under the same keys (`sessions`, `loader`) with different services; client packages consume the wire vocabulary through pure type subpaths (`@deepseek-ai/dsh-session/types` and kin) so no host augmentation rides into the client program. +Dev equals prod: plugins rebuild under `tsdown --watch`, refresh reloads the same chain; vite serves only the shell (`apps/web`). Type universes stay split at the aggregate level — `tsconfig.host.json` is the host program and `tsconfig.client.json` the client program, both referenced by the solution root `tsconfig.json` — because both sides merge cordis `Context` under the same keys (`sessions`, `loader`) with different services; client packages consume the wire vocabulary through pure type subpaths (`@deepseek-ai/dsh-session/types` and kin) so no host augmentation rides into the client program. ## 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 **lives in the runtime package** (zustand vanilla with draft-based updates, `flush: 'sync'` by default with opt-in `'raf'` batching, opt-in whole-value localStorage persistence, dev-mode deep freeze — all exported from `runtime`'s `./client` main entry, no subpath): store products are bare observable sources with no hook members. Plugins reach the engine only through `defineStore` declarations per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). web-react composes every hook at the binding site (`bindSnapshotSelector`, per-source cached) from the one data contract React consumes: `ObservableSnapshot` (`getSnapshot`/`subscribe`) — a Session object and a snapshot store both satisfy it. Business plugin packages depend on runtime and ui-slots only; web-react is shell-only glue. - `bindSnapshotSelector(source)`: binds a source into a typed selector hook over uSES-with-selector. The four uSES contract clauses hold by construction: getSnapshot returns the cached reference; subscribe is a bind-time closure (reference-stable forever); pure CSR passes no server snapshot; equality defaults to `Object.is` with `shallowEqual` opt-in per call. - `useInvoke(fn)`: wraps an async action into a stable trigger plus pending flag; pending rides a per-hook external store read through uSES (no setState on the render path), concurrent invocations are counted, and the invoke reference never changes. - Equality protocol, whole chain: producers use structural sharing; consumers short-circuit with `Object.is` or `shallowEqual`; `React.memo` shallow. Deep comparison is banned everywhere. @@ -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..a50dc556cf 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 @@ -39,32 +39,17 @@ Status: implemented **双实例禁令**:模块表包若被内联进插件 bundle,会复制运行时身份(两份 React、两套 store 注册表——一次真实白屏 P0 的根因)。tsdown client 预设在构建期把守纯度:模块表包的裸名 import 必须解析为 external(适用时改写为其 `/client` 形态),其余任何非 inline 安全 wire/类型层的 workspace 泄漏都令构建大声失败(`packages/client/tsdown.client.ts`,由 `scripts/client-bundle-purity.spec.ts` 钉住)。 -dev 与 prod 同链:插件在 `tsdown --watch` 下重编译,刷新即重走同一条链;vite 只管壳(`apps/web`)。类型宇宙在聚合层拆分——根 `tsconfig.json` 是 host program,`tsconfig.client.json` 是 client program,因为两侧都在相同键(`sessions`、`loader`)上对 cordis `Context` 做声明合并且服务不同;client 包经纯类型子路径(`@deepseek-ai/dsh-session/types` 等)消费协议词汇,host 侧的声明合并不会搭车进入 client program。 +dev 与 prod 同链:插件在 `tsdown --watch` 下重编译,刷新即重走同一条链;vite 只管壳(`apps/web`)。类型宇宙在聚合层拆分——`tsconfig.host.json` 是 host program、`tsconfig.client.json` 是 client program,二者由 solution 根 `tsconfig.json` 引用,因为两侧都在相同键(`sessions`、`loader`)上对 cordis `Context` 做声明合并且服务不同;client 包经纯类型子路径(`@deepseek-ai/dsh-session/types` 等)消费协议词汇,host 侧的声明合并不会搭车进入 client program。 ## 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 引擎**住 runtime 包**(zustand vanilla + 草稿式更新,缺省 `flush: 'sync'`,帧驱动 store 可选 `'raf'` 合批,可选整值 localStorage 持久化,dev 深冻结——全部从 `runtime` 的 `./client` 主出口导出,无子路径):store 产物是裸的可观察源,不带任何 hook 成员。插件只经 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 的 `defineStore` 声明触及引擎。web-react 在绑定处(`bindSnapshotSelector`,按源缓存)从 React 消费的唯一数据契约合成每个 hook:`ObservableSnapshot`(`getSnapshot`/`subscribe`)——Session 对象与快照 store 同构满足它。业务插件包只依赖 runtime 与 ui-slots;web-react 是仅壳可用的胶水。 - `bindSnapshotSelector(source)`:把一个源绑定为经 uSES-with-selector 的带类型 selector hook。uSES 契约四条按构造成立:getSnapshot 恒返缓存引用;subscribe 是绑定期闭包(引用永稳);纯 CSR 不传 server snapshot;相等性缺省 `Object.is`,按调用可选 `shallowEqual`。 - `useInvoke(fn)`:把异步动作包成引用恒定的触发器加 pending 标志;pending 走 per-hook 外部 store 经 uSES 读出(渲染路径零 setState),并发调用计数,invoke 引用永不变。 - 相等性协议,全链一致:生产端结构共享;消费端以 `Object.is` 或 `shallowEqual` 短路;`React.memo` 浅比较。深比较全链禁止。 @@ -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..9e671e3672 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-slot-type-chain-implementation.md: b4ec761b9777f5dfbd59efde8c472f9be4c2e1b6 -2026-07-22-slot-type-chain-implementation.zh.md: 28b6e4a3db0c87322582125825492703e62371b2 +2026-07-22-slot-type-chain-implementation.md: 1e9bd711e8316e2556fe238eb0a20d76e1d0d5b1 +2026-07-22-slot-type-chain-implementation.zh.md: 0eab839d033faac2f2c3356900c7ca1cd69d2dc9 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md index b4ec761b97..1e9bd711e8 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md @@ -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 snapshot store engine (zustand vanilla + immer + optional localStorage persistence) lives in the **runtime package** (`./client` main entry — no subpath), producing bare observable sources; web-react binds them into hooks at the outlet (per-source cached uSES binding). What a store *contains* is the registrant's declaration, written as a factory so no module-level handle exists (a module-scoped handle would be a de-facto singleton surviving plugin reloads): + +```ts ignore-check +export function createChatStore() { + 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 **delivered as a standard-kit seat**: an entry whose `children` declare a session-scope slot receives it as a prop (type in ui-slots, value injected by the renderer) — components never value-import it. It is self-wired (it reads the runtime's current-session state internally; the assembler passes nothing), render-prop shaped — `children(sessionId)` with an `empty` branch, remounting under `key={sessionId}`. `BindingContext` is machinery-internal; business components see zero React contexts. Inject factories execute inside the outlet on purpose (per-entry error boundaries catch them; a crashing registrant blacks out only its own entry while assembly errors rethrow); the outlet reads tree context as a machinery-only implicit parameter — the "identity from the register closure, situation from the tree position" split. + +Rendering lives behind an install seam so the runtime stays React-free: `SlotRenderer` (interface in ui-slots, implementation `createSlotRenderer()` in web-react) is installed once at shell boot via `ctx.slots.install(...)`; double install and render-before-install throw. Ownership bookkeeping is a single `Map` in the service — ledger, slots, contributions, render bindings, and store instances all live and die on the one entry axis, which closes the stale-authority window across plugin reloads by construction (a disposed entry's captured `renderSlot` throws a stale-authorization error on entry). + +### 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..0eab839d03 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md @@ -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 归注册方 + +框架拥有恰好一台订阅机械:快照 store 引擎(zustand vanilla + immer + 可选 localStorage 持久化)住 **runtime 包**(`./client` 主出口——无子路径),产出裸的可观察源;web-react 在 outlet 处把它们绑定成 hook(按源缓存的 uSES 绑定)。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` 是框架组件,**以标配席形式送达**:`children` 里声明了 session scope 坑的 entry 经 prop 收到它(类型住 ui-slots,值由渲染器注入)——组件永不对它做值 import。它框架自接线(内部自读 runtime 的当前会话状态,装配方零传参),render-prop 形——`children(sessionId)` 外加 `empty` 分支,以 `key={sessionId}` 重挂。`BindingContext` 属机械内部;业务组件可见的 React Context 为零。inject 工厂有意在 outlet 内部执行(per-entry 错误边界接得住它们;崩溃的注册方只黑掉自己那一格,装配错误则重抛);outlet 把树上语境当作仅机械可用的暗参读取——即「身份出自 register 闭包、现场出自树位置」的分工。 + +渲染住在一条安装缝之后,runtime 因此保持 React-free:`SlotRenderer`(接口住 ui-slots,实现 `createSlotRenderer()` 住 web-react)在壳 boot 时经 `ctx.slots.install(...)` 安装一次;双重安装与安装前渲染均 throw。归属记账是服务里的单一 `Map`——账本、坑、贡献、渲染绑定、store 实例全部沿同一条 entry 轴生灭,跨插件重载的陈旧权威窗口由此在构造上关闭(已 dispose 的 entry 所捕获的 `renderSlot`,一进入口即抛陈旧授权(stale-authorization)错误)。 + +### 类型链实现裁定 + +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-06-11-tsdown-over-dumble.md b/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.md index 4075d4738a..0629acabb4 100644 --- a/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.md +++ b/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.md @@ -15,7 +15,7 @@ Replace dumble with **tsdown** (rolldown-based, ~2.5M downloads/week, VoidZero-b - Root `tsdown.config.ts` with `workspace: ['vendor/*', 'packages/*/*']` (explicit globs keep bundling to vendored Cordis and the TypeScript package tree; `workspace: true` would also discover example manifests and non-bundled workspace members). - Shared shape: entry `lib/types/index.js`, `outDir: 'lib'`, ESM, `platform: node`, `target: es2024`, `fixedExtension: false` (keeps `.js` for `"type": "module"` packages), `dts: false` (tsc -b owns declarations), `clean: false` (lib/ also holds TSC's `lib/types` intermediate tree). The entry was originally `src/index.ts`; the [TSC-first build Agent Note](2026-06-17-ts-build-config.md) later moved tsdown to bundling TSC-emitted JS so TypeScript transform behavior comes from one compiler. - Two per-package overrides in vendor/ (ours, like the regenerated tsconfigs; logged in vendor/README.md): schemastery (dual `.mjs`/`.cjs` via `outExtensions`), logger-console (two single-entry passes so the shared base class is inlined into each entry instead of a hash-named chunk, matching upstream's published shape). -- `scripts/build.ts` deleted; `pnpm run build` = `tsc -b tsconfig.build.json && tsdown`. +- `scripts/build.ts` deleted; `pnpm run build` = `tsc -b && tsdown` (the root solution owns the emit graph). ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md index 8f67b6fc2f..529400b8d7 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md @@ -1,7 +1,9 @@ -# Agent Note: TSC-first build and one tsconfig +# Agent Note: TSC-first build and one compiler ownership Status: implemented +> Root project topology (which tsconfig owns which graph) has since moved to a solution root over two aggregate programs; see the [solution-root note](2026-07-22-tsconfig-solution-root-two-aggregates.md). The tsc-first pipeline decided here is unchanged. + ## Problem The current TypeScript build and typecheck setup had these issues: @@ -28,29 +30,29 @@ In-package relative imports use explicit `.ts` specifiers. `pnpm run build` is a two-stage build: -- Stage 1: `tsc -b tsconfig.build.json` emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/types`. This is the authoritative TypeScript compilation result. For publish we keep `.d.ts` / `.d.ts.map` and ignore `.js` / `.js.map`. - - The build project uses the project-reference graph that `tsc -b` compiles. For example, root `tsconfig.build.json` references package and vendor tsconfigs. It validates and emits package/vendor build results. +- Stage 1: `tsc -b` over the root solution emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/types`. This is the authoritative TypeScript compilation result. For publish we keep `.d.ts` / `.d.ts.map` and ignore `.js` / `.js.map`. + - The graph is the project-reference graph reachable from the root solution `tsconfig.json` through the two aggregates ([topology](2026-07-22-tsconfig-solution-root-two-aggregates.md)). It validates and emits package/vendor build results. - Stage 2: a bundler reads the emitted JS under `lib/types` and writes the bundled runtime entry as `lib/index.js` or `lib/index.mjs` (follow current behavior). This stage is bundling only. It must not read TypeScript source or emit declarations. `tsdown` is no longer the owner of TypeScript compilation or declaration output. -`pnpm run typecheck` runs build mode over the root `tsconfig.json`. -- The root `tsconfig.json` is the single development/typecheck project. It typechecks examples, tests, and scripts with `noEmit`, and validates package/vendor source through references. -- Referenced package/vendor projects keep the same emit behavior as build, so typecheck can refresh their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`. -- The root no-emit project disables `rewriteRelativeImportExtensions`; it emits nothing and includes tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled. +`pnpm run typecheck` runs the same `tsc -b` graph. +- The aggregates (`tsconfig.host.json`, `tsconfig.client.json`) typecheck examples, tests, and scripts with `noEmit`, and validate package/vendor source through references. +- Referenced package/vendor projects keep the same emit behavior as build, so typecheck refreshes their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`. +- The no-emit aggregates disable `rewriteRelativeImportExtensions`; they emit nothing and include tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled. The command orchestration shape is: ```sh pnpm run build: -tsc -b tsconfig.build.json +tsc -b tsdown pnpm run verify-node-next-types: tsx scripts/verify-node-next-types.ts pnpm run typecheck: -tsc -b tsconfig.json +tsc -b ``` `pnpm run demo:*` still runs `src` directly through tsx and root paths, without a compile step. @@ -65,7 +67,7 @@ tsc -b tsconfig.json Build responsibilities are clearer: - Each module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as `tsx` and `vitest`. -- The `build` command uses `tsconfig.build.json`. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`. +- The `build` command drives the root solution graph. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`. - `lib/types/*.d.ts` and `.d.ts.map` are the publish declaration output. - `lib/types/*.d.ts` uses explicit `.ts` relative specifiers, which TypeScript's NodeNext/Node16 resolver maps to sibling `.d.ts` files. - `lib/types/*.js` is only a bundler input and must not be used as a runtime entry or public import target. 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/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml new file mode 100644 index 0000000000..d191386725 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-tsconfig-solution-root-two-aggregates.md: 19c229693b98ff3825caf935fa647ab85aff0f56 +2026-07-22-tsconfig-solution-root-two-aggregates.zh.md: becc43de1ef2f6a53b0f6c2285eb64d9b42604f1 diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md new file mode 100644 index 0000000000..19c229693b --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md @@ -0,0 +1,45 @@ +# Agent Note: Solution root over two aggregate programs + +Status: implemented + +English | [中文](2026-07-22-tsconfig-solution-root-two-aggregates.zh.md) + +## Problem + +The GUI split introduced a second aggregate program (`tsconfig.client.json`, [layering RFC](../architecture/2026-07-19-gui-layering-and-rpc-protocol.md)) while the root `tsconfig.json` kept doubling as the host aggregate, and `tsconfig.build.json` remained a third, hand-maintained full emit graph. That triple bookkeeping produced four concrete asymmetries: + +- The typecheck and build references lists drifted apart (`packages/goal/command-goal` was in the typecheck graph but missing from the build graph). +- The lefthook pre-push hook ran `tsc -b tsconfig.json` only, so client-side type breakage passed the local checkpoint and surfaced in CI. +- tsserver discovers only configs named `tsconfig.json`, so client test files sat on no discoverable config chain and fell back to inferred projects (no paths, wrong lib/jsx). +- The vitest configs pointed at three different resolution sources (`tsconfig.vitest.json`, the root config, and one hand-written alias). + +## Decision + +One solution root, two check units, one shared base pair, no separate build or vitest config: + +| File | Role | Forms a program? | +|---|---|---| +| `tsconfig.json` | Solution root: `extends` base, `files: []`, two references; the whole-repo `tsc -b tsconfig.json` graph, the tsserver entry, and the nearest config for get-tsconfig consumers (tsx running `examples/`, `scripts/`, doc fences) whose bare workspace imports resolve through the inherited `paths` | No | +| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map; doubles as the resolution facade for vite-tsconfig-paths (no `include`, so it applies to every importer) | No | +| `tsconfig.base.client.json` | Browser compiler shape (`jsx: react-jsx`, DOM libs, `types: []`) shared by the client aggregate and every `packages/client/*` package | No | +| `tsconfig.host.json` | The former root aggregate, moved verbatim: host packages, examples, tests, scripts, website; excludes `packages/client` | Yes | +| `tsconfig.client.json` | Client packages and their tests; extends `tsconfig.base.client.json` | Yes | + +The load-bearing principle: **cordis `Context` declaration-merge collisions exist only inside a `ts.Program`, never in module resolution.** A solution file forms no program, so referencing both aggregates from one root cannot collide the merges; vite-tsconfig-paths reads only `paths` and `include` and discards types, so one facade may span both sides. The only way to explode is to flatten both sides into a single program — hence two derived disciplines: `tsconfig.base.json` never gains `include`/`files` (it would leak into every extending package and narrow the facade), and every repo-wide `ts.Program` consumer (`scripts/ts-project.ts`, doc-typecheck standalone mode) seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly, never the root solution. Program-backed generators and semantic gates intentionally stay host-only; the client side gets program-backed gates only when a real need arrives. + +Commands collapse to one graph and keep the config name explicit: `typecheck` = `tsc -b tsconfig.json`, `build` = `tsc -b tsconfig.json && tsdown`, lefthook pre-push stays `tsc -b tsconfig.json --pretty false` unchanged (the same line now covers both sides through the solution). `tsconfig.build.json` and `tsconfig.vitest.json` are deleted; all vitest configs point vite-tsconfig-paths at `tsconfig.base.json`. + +The solution root `extends` the base deliberately: `examples/` and `scripts/` have no nearer tsconfig, so tsx (get-tsconfig) resolves their workspace imports through the root file. `extends` restores the `paths` map there while `files: []` keeps the file program-less. Their *type checking* is unaffected by this: examples, scripts, and website files are included by the host aggregate. + +## Alternatives considered + +- **Rename `tsconfig.build.json` to `tsconfig.host.json`** — rejected: the build graph was the full emit graph including all client packages, not a host graph; the name that fits the former root aggregate is `tsconfig.host.json`, and the build graph itself is subsumed by the solution. +- **Point vitest at the root solution** — rejected: a solution has neither `paths` nor `include`, so resolution would become a function of how far the plugin walks references, and the client aggregate's include (tests only, no src) would leave transitive src→src imports unmapped, falling through to `exports` and loading a second copy of module singletons. +- **Keep `tsconfig.vitest.json` as a dedicated facade** — retained only as the fallback if vite-tsconfig-paths mishandles an include-less config; the base file already carries the paths map, and an include-less config applies everywhere, which is strictly wider than the facade's hand-kept include list. + +## Consequences + +- `docs/development.md#typescript-project-layout` is the authoritative description; root `AGENTS.md` carries the two disciplines as conventions. +- The [ts-build-config note](2026-06-17-ts-build-config.md) keeps ownership of the tsc-first build pipeline (tsc emits, tsdown bundles, `.ts` specifiers with `rewriteRelativeImportExtensions`); its former "one root typecheck project" shape is superseded by this note. +- Adding a package registers it in exactly one aggregate's references (host packages in `tsconfig.host.json`, client packages in `tsconfig.client.json`); the build graph needs no separate registration. +- The build gate depends on the typecheck gate: both now drive the same `tsc -b` graph, so running them concurrently would race the same `.tsbuildinfo` files. diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md new file mode 100644 index 0000000000..becc43de1e --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 以 solution 根文件统辖两个聚合 program + +Status: implemented + +[English](2026-07-22-tsconfig-solution-root-two-aggregates.md) | 中文 + +## 问题 + +GUI 拆分引入了第二个聚合 program(`tsconfig.client.json`,见[分层 RFC](../architecture/2026-07-19-gui-layering-and-rpc-protocol.md)),根 `tsconfig.json` 则继续兼任宿主侧聚合,`tsconfig.build.json` 还是第三份手工维护的全量 emit 图。三处账本并行,造成四个具体的不对称: + +- 类型检查与构建的 references 列表逐渐脱节(`packages/goal/command-goal` 在类型检查图里,构建图里却没有)。 +- lefthook 的 pre-push 钩子只运行 `tsc -b tsconfig.json`,客户端侧的类型破坏因此通过本地检查点,直到 CI 才暴露。 +- tsserver 只发现名为 `tsconfig.json` 的配置,客户端测试文件不在任何可发现的配置链上,回落到推断项目(inferred project),既没有 paths,lib/jsx 也不对。 +- 各 vitest 配置指向三个不同的解析来源(`tsconfig.vitest.json`、根配置,外加一处手写别名)。 + +## 决策 + +一个 solution 根文件,两个检查单元,一对共享 base,不再单设 build 或 vitest 配置: + +| 文件 | 角色 | 是否构成 program? | +|---|---|---| +| `tsconfig.json` | solution 根文件:`extends` base、`files: []`、两条 references;同时是全仓 `tsc -b tsconfig.json` 图、tsserver 入口,以及 get-tsconfig 消费方(tsx 运行 `examples/`、`scripts/`、文档围栏代码块)就近命中的配置,其裸 workspace 导入经继承来的 `paths` 解析 | 否 | +| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射;兼任 vite-tsconfig-paths 的解析门面(不含 `include`,因此对每个导入方都生效) | 否 | +| `tsconfig.base.client.json` | 浏览器侧编译形态(`jsx: react-jsx`、DOM lib、`types: []`),由客户端聚合与每个 `packages/client/*` 包共享 | 否 | +| `tsconfig.host.json` | 原根聚合原样迁入:宿主各包、examples、测试、scripts、website;排除 `packages/client` | 是 | +| `tsconfig.client.json` | 客户端各包及其测试;通过 `extends` 继承 `tsconfig.base.client.json` | 是 | + +整个方案立足的原则:**cordis `Context` 的声明合并冲突只存在于同一个 `ts.Program` 内部,从不发生在模块解析中。** solution 文件不构成 program,因此从一个根文件同时引用两个聚合不会让两侧的声明合并相撞;vite-tsconfig-paths 只读取 `paths` 与 `include`、丢弃全部类型信息,因此一个门面可以横跨两侧。唯一会爆炸的做法是把两侧压平进同一个 program,由此推出两条派生纪律:`tsconfig.base.json` 永远不得添加 `include`/`files`(否则会泄漏进每个继承它的包,并收窄门面范围);每个全仓级 `ts.Program` 消费方(`scripts/ts-project.ts`、doc-typecheck 独立模式)都显式以 `tsconfig.host.json` 或 `tsconfig.client.json` 为种子,绝不使用根 solution。基于 program 的生成器与语义门禁有意只留在宿主侧;客户端侧只有在真实需求出现时才引入基于 program 的门禁。 + +各命令收敛到一张图,且显式写出配置名:`typecheck` = `tsc -b tsconfig.json`,`build` = `tsc -b tsconfig.json && tsdown`,lefthook pre-push 保持 `tsc -b tsconfig.json --pretty false` 不变(经由 solution,这同一行命令现已覆盖两侧)。`tsconfig.build.json` 与 `tsconfig.vitest.json` 删除;所有 vitest 配置都把 vite-tsconfig-paths 指向 `tsconfig.base.json`。 + +solution 根文件刻意 `extends` base:`examples/` 与 `scripts/` 没有更近的 tsconfig,tsx(get-tsconfig)通过根文件解析它们的 workspace 导入。`extends` 把 `paths` 映射带回根文件,`files: []` 则让它始终不构成 program。这不影响两者的*类型检查*:examples、scripts 与 website 的文件由宿主聚合纳入。 + +## 考虑过的替代方案 + +- **把 `tsconfig.build.json` 改名为 `tsconfig.host.json`**——不予采纳:构建图是包含全部客户端包的全量 emit 图,不是宿主图;`tsconfig.host.json` 这个名字对应的是原根聚合,而构建图本身已被 solution 吸收。 +- **让 vitest 指向根 solution**——不予采纳:solution 既没有 `paths` 也没有 `include`,解析结果将取决于插件沿 references 走多远;且客户端聚合的 include 只收测试、不收 src,传递的 src→src 导入会失去映射,回落到 `exports`,加载出模块单例的第二份副本。 +- **保留 `tsconfig.vitest.json` 作为专用门面**——仅保留为后备方案:若 vite-tsconfig-paths 处理不了无 include 的配置再启用;base 文件已经携带 paths 映射,而无 include 的配置处处生效,严格宽于该门面手工维护的 include 列表。 + +## 后果 + +- `docs/development.md#typescript-project-layout` 是权威描述;根 `AGENTS.md` 以约定形式收录上述两条纪律。 +- [ts-build-config Agent Note](2026-06-17-ts-build-config.md) 继续拥有 tsc 先行的构建流水线(tsc 负责输出,tsdown 负责打包,`.ts` 说明符配合 `rewriteRelativeImportExtensions`);其原先「单一根类型检查项目」的形态由本文取代。 +- 新增一个包只登记进恰好一个聚合的 references(宿主包进 `tsconfig.host.json`,客户端包进 `tsconfig.client.json`);构建图无需另行登记。 +- 构建门禁依赖类型检查门禁:两者现在驱动同一张 `tsc -b` 图,并发运行会在同一批 `.tsbuildinfo` 文件上竞态。 diff --git a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.md b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.md index fa544d6ddb..f9286aeefd 100644 --- a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.md +++ b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.md @@ -6,13 +6,13 @@ Status: proposed Package and gate inventories are repeated across TypeScript project references, package docs, CI prose, and Knip overrides. Most restate package layout, manifest data, or aggregate command contents. Each new package therefore creates avoidable synchronization points. -The [package hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md) already removed several of these by hand: `scripts/publint-all.ts` now derives its list from the `packages//` layout, and the two `tsconfig` `paths` maps collapsed to one `@deepseek-ai/dsh-*` wildcard. What remains is the inventory that cannot be globbed away — chiefly `tsconfig.build.json`'s project `references`, which TypeScript requires as an explicit array (no wildcard form). +The [package hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md) already removed several of these by hand: `scripts/publint-all.ts` now derives its list from the `packages//` layout, and the two `tsconfig` `paths` maps collapsed to one `@deepseek-ai/dsh-*` wildcard. What remains is the inventory that cannot be globbed away — chiefly the aggregate configs' (`tsconfig.host.json`, `tsconfig.client.json`) project `references`, which TypeScript requires as explicit arrays (no wildcard form). Static lists are appropriate when they encode policy; they are needless friction when they duplicate manifest data or layout facts that already exist in `package.json`, workspace globs, or the package hierarchy. ## Proposal -Make the remaining package/gate inventories discoverable. A single canonical source — the `packages//` hierarchy plus package manifests — should drive `tsconfig.build.json`'s `references`, the module graph, and any other full-package list, with a generate-and-verify step (the existing `gen-module-graph` / `gen-cordis-catalog` pattern: a generator writes the artifact, a `--check` mode in `hygiene`/`doc-sync` fails on a stale committed copy). Module graph generation already reads package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list. +Make the remaining package/gate inventories discoverable. A single canonical source — the `packages//` hierarchy plus package manifests — should drive the aggregates' `references`, the module graph, and any other full-package list, with a generate-and-verify step (the existing `gen-module-graph` / `gen-cordis-catalog` pattern: a generator writes the artifact, a `--check` mode in `hygiene`/`doc-sync` fails on a stale committed copy). Module graph generation already reads package manifests. `doc-sync` should be the one command that defines and prints its sub-gates, with docs linking to that command rather than restating a second list. The hierarchy does not need to encode every fact about a package, but it should encode the broad maintenance policy: core/product packages, integrations, capability seams, and support/test/example packages should not all require a hand-maintained exception list before scripts can tell them apart. @@ -20,7 +20,7 @@ One cataloged item needs no generator at all: folding the e2e entry glob into kn ## Acceptance criteria -- `tsconfig.build.json` project `references` are generated from the hierarchy (a generator emits them; a `--check` gate fails when the committed copy is stale), rather than hand-maintained. +- Aggregate-config project `references` are generated from the hierarchy (a generator emits them; a `--check` gate fails when the committed copy is stale), rather than hand-maintained. - Adding a package does not require editing a static package list for any gate. - Docs describe the source of truth rather than repeating generated inventories. - CI invokes the aggregate commands and lets those commands own their sub-gate lists. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f52274cddd..2c6036968f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: - uses: actions/cache/restore@v4 with: path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- @@ -173,7 +173,7 @@ jobs: - uses: actions/cache/restore@v4 with: path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- @@ -237,7 +237,7 @@ jobs: - uses: actions/cache@v4 with: path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- @@ -309,7 +309,7 @@ jobs: - uses: actions/cache@v4 with: path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- @@ -525,7 +525,7 @@ jobs: if: matrix.platform == 'linux' with: path: .cache/eslint - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- diff --git a/AGENTS.md b/AGENTS.md index 8dcdc059f4..8007f32b90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,6 +100,8 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Misconfiguration fails loud** at load when self-contained, otherwise at the earliest resolvable point; never silently skip a missing referent. - **Opaque cross-boundary ids are branded** (`Branded` from `dsh-brand`), never bare `string`. - **Trust TypeScript at typed same-process seams.** Do not add runtime validation, fallback behavior, or hostile-input tests solely for values the static interface requires; validate at parser/config, queued, model/tool JSON, durable/file, worker, process, and wire boundaries. +- **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)). +- **`ts.Program` consumers seed `tsconfig.host.json` or `tsconfig.client.json`, never the root solution** — one program holding both sides collides the cordis `Context` merges ([layout](docs/development.md#typescript-project-layout)). - **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement. - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. diff --git a/README.i18n.yaml b/README.i18n.yaml index 020593bf20..41a7a06404 100644 --- a/README.i18n.yaml +++ b/README.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 -README.md: 8b34d6177834e1c410b2c3ecaf32154de42520b4 -README.zh.md: cf029cc0bb8c5aa527d14753803eac4c28ab9de7 +README.md: 27774fd3e0ffc821e7287f6153906a5d21530dc2 +README.zh.md: b7d08f2bc948d0a6d388dd672c5a702bc7da6f6f diff --git a/README.md b/README.md index 8b34d61778..27774fd3e0 100644 --- a/README.md +++ b/README.md @@ -2,32 +2,76 @@ English | [中文](README.zh.md) -The **DeepSeek Harness SDK** is a plugin-based SDK for building agent harnesses. +DeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK. + +It uses an architecture where **everything is a plugin**. ## Install -Install the `dsh` coding agent with one line — it needs `git` and Node `^22.19 || >=24`, and offers to install `pnpm` if it is missing: +Install `dsh` with one command: ```sh curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh ``` -It clones the harness to `~/.dsh/source`, runs `pnpm install`, symlinks `dsh` into `~/.local/bin` (offering to add it to your PATH), prompts once for your `DEEPSEEK_API_KEY`, and launches `dsh`; re-running it updates an existing checkout. Run from inside a checkout (`sh scripts/install.sh`) it reuses that checkout and skips the clone. The overridable `DSH_*` variables are documented in [`scripts/install.sh`](scripts/install.sh). +The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key. + +The installer clones DeepSeek Harness to `~/.dsh/source`, links `dsh` into `~/.local/bin`, and launches it. Re-running the command updates the checkout. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options. + +## Use DeepSeek Harness + +### Web UI + +For the recommended local interface, build the frontend after installation and after each update, then start the Web UI: + +```sh +pnpm --dir ~/.dsh/source run build:web +dsh web +``` + +The Web UI is served at `http://127.0.0.1:3080` by default. + +### TUI + +Start the full-screen terminal interface: + +```sh +dsh +``` + +### Headless + +Run one task, print the final answer, and exit: + +```sh +dsh -p "summarize this workspace" +``` + +## Why DeepSeek Harness + +Built-in capabilities cover file reading, editing, and search; shell execution; reusable skills; task tracking; subagents and workflows; persistent sessions; and context compaction. The TUI also includes Plan Mode. + +- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design. +- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode). +- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md). + +## Community + +Follow DeepSeek Harness on Twitter for project updates. ## Development -This monorepo is built on the [Cordis](https://github.com/cordiverse/cordis) framework (vendored as source under `vendor/`), microkernel-style: everything is a plugin. - ```sh pnpm install -pnpm run test # vitest -# Agent demos require DEEPSEEK_API_KEY. -pnpm run demo:tui # full-screen TUI coding agent -pnpm run demo:headless "task" # one-shot coding agent -pnpm run demo:cordis # self-referential agent demo -pnpm run demo:acp # ACP server agent demo +pnpm run test:coverage ``` -For humans, start with the [development guide](docs/development.md) for local setup, hooks, environment variables, and quality gates, then read the [architecture design](docs/architecture.md) and [documentation graph index](docs/graph-atlas.md) before package work. Local context lives in [packages/](packages/) and [vendor/](vendor/). +Start with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages. For agents, follow [AGENTS.md](AGENTS.md). + +DeepSeek Harness is currently pre-release. + +## License + +[BSD 3-Clause](LICENSE) diff --git a/README.zh.md b/README.zh.md index cf029cc0bb..b7d08f2bc9 100644 --- a/README.zh.md +++ b/README.zh.md @@ -2,32 +2,80 @@ [English](README.md) | 中文 -**DeepSeek Harness SDK** 是用于构建 agent harness(智能体框架)的 SDK,采取基于插件的设计。 +DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。 + +它采用了**一切皆插件**的架构。 ## 安装 -一行命令即可安装 `dsh` 编码智能体——需要 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时会询问是否代为安装: +使用一条命令安装 `dsh`: ```sh curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh ``` -脚本会把 harness 克隆到 `~/.dsh/source`,运行 `pnpm install`,把 `dsh` 软链接到 `~/.local/bin`(并询问是否加入 PATH),提示输入一次 `DEEPSEEK_API_KEY`,随后启动 `dsh`;再次运行会更新已有的检出。若在检出目录内运行(`sh scripts/install.sh`),脚本会复用当前检出并跳过克隆。可覆盖的 `DSH_*` 变量见 [`scripts/install.sh`](scripts/install.sh)。 +安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。 + +安装器会将 DeepSeek Harness 克隆到 `~/.dsh/source`,把 `dsh` 链接到 `~/.local/bin`,然后启动它。再次运行该命令会更新源码目录。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。 + +## 使用 DeepSeek Harness + +### Web UI + +推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI: + +```sh +pnpm --dir ~/.dsh/source run build:web +dsh web +``` + +Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 + +### TUI + +启动全屏终端界面: + +```sh +dsh +``` + +### Headless + +运行一项任务,打印最终答案后退出: + +```sh +dsh -p "summarize this workspace" +``` + +## 为什么选择 DeepSeek Harness + +内置功能涵盖文件读取、编辑与搜索、shell 执行、可复用 skill(技能)、任务跟踪、subagent 与工作流、持久化会话,以及上下文压缩(context compaction)。TUI 还包含 Plan Mode。 + +- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。 +- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。 +- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。 + +## 社区 + +扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。 + +

+ DeepSeek Harness 微信社区二维码 +

## 开发 -本 monorepo 基于 [Cordis](https://github.com/cordiverse/cordis) 框架构建(以源码形式收录在 `vendor/` 下),采用微内核风格:所有功能都以插件形式提供。 - ```sh pnpm install -pnpm run test # vitest -# Agent demos require DEEPSEEK_API_KEY. -pnpm run demo:tui # full-screen TUI coding agent -pnpm run demo:headless "task" # one-shot coding agent -pnpm run demo:cordis # self-referential agent demo -pnpm run demo:acp # ACP server agent demo +pnpm run test:coverage ``` -面向开发者:先读[开发指南](docs/development.md),了解本地环境搭建、钩子、环境变量与质量门禁,动手改 package 之前再读[架构设计](docs/architecture.md)和[文档关系图索引](docs/graph-atlas.md)。局部上下文见 [packages/](packages/) 与 [vendor/](vendor/)。 +请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。 面向 agent:遵循 [AGENTS.md](AGENTS.md)。 + +DeepSeek Harness 目前处于预发布阶段。 + +## 许可证 + +[BSD 3-Clause](LICENSE) diff --git a/assets/community-wecom-survey.png b/assets/community-wecom-survey.png new file mode 100644 index 0000000000..d6f6040ba7 Binary files /dev/null and b/assets/community-wecom-survey.png differ diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 6fd3feebbd..234db6273e 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.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 -adding-a-package.md: 556a48493af4452c178634c0abb4e23e2419dd8e -adding-a-package.zh.md: 5f7e4692233448c746d25e4808c078c390cf39e6 +adding-a-package.md: 1859310965538b35a353ee05c94b01d1093a3e43 +adding-a-package.zh.md: 22f574a0469609e44f5c55957560ff0f04b9a053 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 556a48493a..1859310965 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -32,10 +32,11 @@ In-package relative imports use explicit `.ts` specifiers in source (for example | File | Change | |---|---| | `tsconfig.base.json` | no edit for an existing group; for a new group, add a `./packages//*/src` candidate to the `@deepseek-ai/dsh-*` wildcard | -| `tsconfig.json` | add `{ "path": "./packages//" }` to `references` | -| `tsconfig.build.json` | add `{ "path": "./packages//" }` to `references` | +| `tsconfig.host.json` (host-side package) or `tsconfig.client.json` (client-side package) | add `{ "path": "./packages//" }` to `references` — exactly one aggregate, never both ([layout](../development.md#typescript-project-layout)) | | `knip.json` | only if the package has non-`*.spec.ts` entries (e.g. `*.e2e.ts` → add a per-workspace override like `packages/llm/llm-deepseek`) | +A `packages/client/*` package additionally extends `tsconfig.base.client.json` instead of `tsconfig.base.json`, and a client plugin package declares `dshClient` in package.json, exports `./client`, and calls the shared tsdown preset (`packages/client/tsdown.client.ts`) — see [packages/client/AGENTS.md](../../packages/client/AGENTS.md) for the client-side contract. + Covered automatically by globs or package-manifest discovery — no edits needed: root `package.json` workspaces, `scripts/publint-all.ts`, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`, `scripts/check-workspace-constraints.ts`. ## 3. Decide the package topology diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index 5f7e469223..22f574a046 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -32,10 +32,11 @@ package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-c | 文件 | 变更 | |---|---| | `tsconfig.base.json` | 已有分组无需编辑;新分组需为 `@deepseek-ai/dsh-*` 通配符添加 `./packages//*/src` 候选路径 | -| `tsconfig.json` | 在 `references` 中添加 `{ "path": "./packages//" }` | -| `tsconfig.build.json` | 在 `references` 中添加 `{ "path": "./packages//" }` | +| `tsconfig.host.json`(host 侧包)或 `tsconfig.client.json`(client 侧包) | 在 `references` 中添加 `{ "path": "./packages//" }`——恰好一个聚合,绝不两个都加([布局](../development.md#typescript-project-layout)) | | `knip.json` | 仅当包有非 `*.spec.ts` 入口时需要(如 `*.e2e.ts` → 添加 per-workspace override,参照 `packages/llm/llm-deepseek`) | +`packages/client/*` 包改为 extends `tsconfig.base.client.json`(而非 `tsconfig.base.json`);client 插件包还需在 package.json 声明 `dshClient`、导出 `./client`、调用共享 tsdown preset(`packages/client/tsdown.client.ts`)——client 侧见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md)。 + 以下内容由 glob 或包 manifest 发现机制自动覆盖,无需手动编辑:根 `package.json` workspaces、`scripts/publint-all.ts`、`tsdown.config.ts`、`vitest.config.ts`、`eslint.config.mjs`、`scripts/check-workspace-constraints.ts`。 ## 3. 确定包拓扑 diff --git a/docs/cookbook/adding-a-vendored-package.i18n.yaml b/docs/cookbook/adding-a-vendored-package.i18n.yaml index b3ca7ca791..98c5ee7696 100644 --- a/docs/cookbook/adding-a-vendored-package.i18n.yaml +++ b/docs/cookbook/adding-a-vendored-package.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 -adding-a-vendored-package.md: 1b82f2e582ca5cd040a7f3237505848dbb304fae -adding-a-vendored-package.zh.md: 7245682ef8b7d85ace2c626f8d47aa36f739506b +adding-a-vendored-package.md: 71ca9fccc9418348784dbb6668127242e4fb45d2 +adding-a-vendored-package.zh.md: c340630aebeda0ec293a835cdfc8d15d71cd7801 diff --git a/docs/cookbook/adding-a-vendored-package.md b/docs/cookbook/adding-a-vendored-package.md index 1b82f2e582..71ca9fccc9 100644 --- a/docs/cookbook/adding-a-vendored-package.md +++ b/docs/cookbook/adding-a-vendored-package.md @@ -38,8 +38,7 @@ Local relative imports/exports in vendored TypeScript source use explicit `.ts` | File | Change | |---|---| | `tsconfig.base.json` | add `"": ["./vendor//src"]` to `paths` | -| `tsconfig.json` | add `{ "path": "./vendor/" }` to `references` | -| `tsconfig.build.json` | add `{ "path": "./vendor/" }` to `references` (before the `packages/*` entries) | +| `tsconfig.host.json` | add `{ "path": "./vendor/" }` to `references` (before the `packages/*` entries; vendored code enters the graph through the host aggregate only) | | `vendor/README.md` | add a manifest table row (dir, npm name, version, upstream repo, commit SHA) and log any local modifications | | `scripts/publint-all.ts` | only if the vendored package is itself published from here (vendored deps normally are not — skip) | @@ -57,4 +56,4 @@ pnpm run typecheck pnpm run build && pnpm run test && pnpm run constraints ``` -The source `paths` map is shared by build and root typecheck configs. The important isolation boundary is the project-reference graph: vendored source must be referenced through its own `vendor//tsconfig.json`, not pulled into a root strict program. +The source `paths` map lives once in `tsconfig.base.json` and serves every graph. The important isolation boundary is the project-reference graph: vendored source must be referenced through its own `vendor//tsconfig.json`, not pulled into an aggregate's strict program ([layout](../development.md#typescript-project-layout)). diff --git a/docs/cookbook/adding-a-vendored-package.zh.md b/docs/cookbook/adding-a-vendored-package.zh.md index 7245682ef8..c340630aeb 100644 --- a/docs/cookbook/adding-a-vendored-package.zh.md +++ b/docs/cookbook/adding-a-vendored-package.zh.md @@ -38,8 +38,7 @@ vendored TypeScript 源码中的本地相对导入/导出在复制后使用显 | 文件 | 修改内容 | |---|---| | `tsconfig.base.json` | 在 `paths` 中添加 `"": ["./vendor//src"]` | -| `tsconfig.json` | 在 `references` 中添加 `{ "path": "./vendor/" }` | -| `tsconfig.build.json` | 在 `references` 中添加 `{ "path": "./vendor/" }`(置于 `packages/*` 条目之前) | +| `tsconfig.host.json` | 在 `references` 中添加 `{ "path": "./vendor/" }`(置于 `packages/*` 条目之前;vendored 代码只经 host 聚合进图) | | `vendor/README.md` | 添加一行 manifest 表格行(dir、npm name、version、upstream repo、commit SHA)并记录所有本地修改 | | `scripts/publint-all.ts` | 仅当该 vendored 包本身从此仓库发布时才需要(vendored 依赖通常不发布——跳过) | @@ -57,4 +56,4 @@ pnpm run typecheck pnpm run build && pnpm run test && pnpm run constraints ``` -源码 `paths` 映射由构建配置和根类型检查配置共享。重要的隔离边界是 project-reference 图:vendored 源码必须通过其自身的 `vendor//tsconfig.json` 被引用,而非被拉入根目录的严格程序中。 +源码 `paths` 映射只在 `tsconfig.base.json` 存在一份,服务所有图。重要的隔离边界是 project-reference 图:vendored 源码必须通过其自身的 `vendor//tsconfig.json` 被引用,而非被拉入某个聚合的严格程序中([布局](../development.md#typescript-project-layout))。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 4ea0bafef3..40828094ce 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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 -development.md: 3559b09d86395707f222aad0a281c9db1246c24f -development.zh.md: 8664a3291c04a5338fdadbce8f24b160cc9ec0a8 +development.md: 4294038e40aa774a006874e6641ca63eea44beeb +development.zh.md: 1f07c95dd60d0554b945c29e6e3ba8bc6ca9841a diff --git a/docs/development.md b/docs/development.md index 3559b09d86..4294038e40 100644 --- a/docs/development.md +++ b/docs/development.md @@ -33,7 +33,26 @@ Run typecheck once after a fresh clone: pnpm run typecheck ``` -That first typecheck runs the package/vendor build graph and the root no-emit `tsconfig.json` graph for examples, tests, and scripts. The root graph uses the same source `paths` map but relies on project references so vendored code is checked under its own tsconfig settings. +That first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below. + +## TypeScript project layout + +The repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them. + +| File | Role | Forms a program? | +|---|---|---| +| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No | +| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes | +| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes | +| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No | +| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No | + +Host and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow: + +- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope. +- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need. + +Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md). If a relevant local check consumes built package output, build once first: @@ -59,7 +78,7 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook is configured in `lefthook.yml` as a fast local checkpoint: - `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard. -- `pre-push` runs only the incremental repository typecheck. +- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates). The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. @@ -80,7 +99,7 @@ pnpm run test # unit tests pnpm run test:coverage # unit tests with per-file coverage gates pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks -pnpm run typecheck # build package/vendor outputs, then typecheck examples, tests, and scripts +pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs diff --git a/docs/development.zh.md b/docs/development.zh.md index 8664a3291c..1f07c95dd6 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -33,7 +33,26 @@ pnpm exec lefthook install --force pnpm run typecheck ``` -首次类型检查会执行 package/vendor 的构建图,以及根目录下用于示例、测试和脚本的 no-emit `tsconfig.json` 项目图。根图使用同一份源码 `paths` 映射,但依赖 project references,因此 vendor 代码在它自己的 tsconfig 设置下被检查。 +首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。 + +## TypeScript 项目布局 + +仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。 + +| 文件 | 角色 | 是否构成 program? | +|---|---|---| +| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 | +| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 | +| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 | +| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 | +| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 | + +host 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律: + +- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。 +- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。 + +静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。 如果相关的本地检查需要使用构建后的包产物,请先构建一次: @@ -59,7 +78,7 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点: - `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; -- `pre-push` 只运行仓库增量类型检查。 +- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。 @@ -80,7 +99,7 @@ pnpm run test # unit tests pnpm run test:coverage # unit tests with per-file coverage gates pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks -pnpm run typecheck # build package/vendor outputs, then typecheck examples, tests, and scripts +pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs diff --git a/docs/testing.md b/docs/testing.md index 85601ab6c9..85798cee3e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -29,6 +29,10 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword - A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. - "Real entry path" means the published artifact: a package `bin` runs built `lib/bin.js` under plain `node`, exposing failures tsx masks (settle races, module resolution, swallowed load failures). The same applies to non-index runtime entries (the worker-thread sibling `lib/worker.cjs`) and singleton modules shared across bundles (`packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. +## Test resolution: source plane only + +- Every vitest config points vite-tsconfig-paths at `tsconfig.base.json`; bare workspace imports resolve to `src` ([layout](development.md#typescript-project-layout)), never through package `exports` to built `lib/` — stale artifacts there load a second copy of module singletons. Built artifacts are consumed only explicitly: `lib`-mode subprocesses and the built smokes below. + ## Test subprocess launch modes - CI and build-having test lanes run every example or Cordis-config subprocess from built `lib/` through the shared dual-mode launcher. Do not hand-write `--import tsx` for these subprocesses. 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/eslint.config.mjs b/eslint.config.mjs index 334d3799af..eec63181df 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -86,7 +86,8 @@ export default tseslint.config( languageOptions: { parserOptions: { // Same shared project service as the src block: test files resolve - // through the root tsconfig (its include covers every tests/ tree). + // through the root solution to tsconfig.host.json (its include covers + // every host tests/ tree). projectService: true, tsconfigRootDir: import.meta.dirname, }, diff --git a/knip.json b/knip.json index dbc1e585de..3a7a443718 100644 --- a/knip.json +++ b/knip.json @@ -88,8 +88,7 @@ }, "packages/client/runtime": { "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" + "tests/**/*.spec.ts" ], "project": [ "src/**/*.ts", @@ -106,6 +105,16 @@ "tests/**/*.tsx" ] }, + "packages/client/web-react": { + "entry": [ + "tests/**/*.spec.tsx" + ], + "project": [ + "src/**/*.ts", + "src/**/*.tsx", + "tests/**/*.tsx" + ] + }, "packages/client/ui-layout": { "entry": [ "tests/**/*.spec.ts", @@ -116,9 +125,6 @@ "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx" - ], - "ignoreDependencies": [ - "@deepseek-ai/dsh-client-ui-slots" ] }, "website": { diff --git a/lefthook.yml b/lefthook.yml index 49a1499976..cf2e6bb11d 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -20,4 +20,4 @@ pre-commit: pre-push: jobs: - name: typecheck - run: node_modules/.bin/tsc -b tsconfig.json --pretty false + run: node_modules/.bin/tsc -b --pretty false diff --git a/package.json b/package.json index 611e1d49e2..38db3a2ac7 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,10 @@ "website" ], "scripts": { - "build": "tsc -b tsconfig.build.json && tsdown", + "build": "tsc -b && tsdown", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", "clean:build": "rm -rf .typecheck packages/*/*/lib vendor/*/lib *.tsbuildinfo", - "typecheck": "tsc -b tsconfig.json tsconfig.client.json", + "typecheck": "tsc -b", "lint": "eslint .", "lint:fix": "eslint . --fix", "duplication": "jscpd --config .jscpd.json packages scripts", diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 4fc767173f..bb7fdfa839 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -19,6 +19,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md Naming notes: +- **Package tsconfig shape:** extends `tsconfig.base.json` (client: `tsconfig.base.client.json`), `rootDir: src`, `outDir: lib/types`, a `references` entry per workspace dependency plus `support/invariants`; registered in exactly one aggregate — host packages in `tsconfig.host.json`, client in `tsconfig.client.json` ([layout](../docs/development.md#typescript-project-layout)). - `src/types.ts` contains only types — no runtime code. - Tests live at package level under `tests/`, not `src/__tests__/`. - A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code. diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index cfe932ed65..d3011ed85f 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -1,42 +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), 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 (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`, React-free): `ConnectionController` → `SessionManager` → `Session` own all business state (event windows, streaming accumulation, reconnect machine), and the snapshot-store engine (zustand/immer, `defineStore`, `shallowEqual`) lives here too — store products are bare observable sources with no hook members. Zero React imports — grep-assertable. +2. **Render machinery** (`web-react`, shell-only glue): the whole ctx↔React boundary — slot renderer/outlets, `SessionProvider`, the uSES bridge. Every hook is composed here at the binding site from bare sources; business plugin packages carry no web-react dependency at all. +3. **Presentation components** (plugin packages' `src/client/`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; everything arrives through the four props shares. Non-negotiables across the layers: -- **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 @@ -63,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/connection/README.md b/packages/client/connection/README.md index a35c9c3d0c..dc9fbb85b8 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-connection -Wire consumer layer (moved verbatim from web-runtime): IApiClient family (WebApiClient/FixtureApiClient), ConnectionController (SSE dual-stream + backoff reconnect), WEB_EVENTS. Contract: api-contracts v3 §3, export inventory in §3.2. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## Model Experience diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 026fc1bf5f..b017d1c9e2 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -21,16 +21,13 @@ export type { ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, } from './api.ts' -export { RpcId, AbstractApiClient, resultOf, transportError } from './api.ts' +export { RpcId, AbstractApiClient, transportError } from './api.ts' -// ---- Connection loop ---- -export { ConnectionController } from './connection.ts' +// ---- Connection loop types (part of the ConnectionHandle.start contract; +// the controller class itself stays package-internal — apply owns the loop, +// tests reach it via src) ---- export type { ConnectionConfig, ConnectionSinks, ConnectionState } -// ---- Platform client subclasses ---- -export { WebApiClient } from './web-api-client.ts' -export { FixtureApiClient, createFixtureApi } from './fixture.ts' - /** Required services (none — this is the wire root). */ export const inject: string[] = [] diff --git a/packages/client/connection/tsconfig.json b/packages/client/connection/tsconfig.json index 3f45ca0231..8b0357cf97 100644 --- a/packages/client/connection/tsconfig.json +++ b/packages/client/connection/tsconfig.json @@ -1,14 +1,8 @@ { - "extends": "../../../tsconfig.base.json", + "extends": "../../../tsconfig.base.client.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/types", - "lib": [ - "ES2024", - "DOM", - "DOM.Iterable" - ], - "types": [] + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/client/i18n/package.json b/packages/client/i18n/package.json index 1e3d81b275..5b5d4e718c 100644 --- a/packages/client/i18n/package.json +++ b/packages/client/i18n/package.json @@ -33,7 +33,7 @@ }, "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/dsh-client-web-react": "workspace:^" + "@deepseek-ai/dsh-client-runtime": "workspace:^" }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/client/i18n/src/client/index.ts b/packages/client/i18n/src/client/index.ts index b994cccace..37e1c0cdb5 100644 --- a/packages/client/i18n/src/client/index.ts +++ b/packages/client/i18n/src/client/index.ts @@ -5,8 +5,13 @@ * 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' +// The snapshot-store engine lives in runtime (store relocation): framework +// data stores like this locale cell use it directly. The store carries no +// hook — a React consumer binds a selector hook via web-react's +// bindSnapshotSelector at its own seam (none exists today; the current +// consumers are translate() reads and test-side subscribe/set). +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { en } from '../locales/en.ts' import { zh } from '../locales/zh.ts' diff --git a/packages/client/i18n/tsconfig.json b/packages/client/i18n/tsconfig.json index dd593f678d..63eb9779ca 100644 --- a/packages/client/i18n/tsconfig.json +++ b/packages/client/i18n/tsconfig.json @@ -1,14 +1,8 @@ { - "extends": "../../../tsconfig.base.json", + "extends": "../../../tsconfig.base.client.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/types", - "lib": [ - "ES2024", - "DOM", - "DOM.Iterable" - ], - "types": [] + "outDir": "lib/types" }, "include": [ "src" @@ -18,7 +12,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../web-react" + "path": "../runtime" }, { "path": "../../support/invariants" diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 01c4172902..66eb5b22ac 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-runtime -Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), Session object layer, ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4. +Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4. ## Model Experience diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index d17ff8e4b4..384b2e2e3c 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -37,10 +37,11 @@ "dependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "immer": "^10.1.1", "react": "^18.2.0", - "@deepseek-ai/dsh-session": "workspace:^" + "zustand": "~4.4.7" }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/client/runtime/src/client/contract/store.ts b/packages/client/runtime/src/client/contract/store.ts new file mode 100644 index 0000000000..ce4444cf36 --- /dev/null +++ b/packages/client/runtime/src/client/contract/store.ts @@ -0,0 +1,244 @@ +/** + * Snapshot store engine (zustand vanilla + immer + subscribeWithSelector + + * rafFlush middleware + opt-in persist + dev freeze) plus the declarative + * shell over it: {@link defineStore} bakes an init/persist/actions literal + * into a {@link StoreHandle}, the registration-side store seat of the slot + * terminal design (§4). Lives in the React-free runtime (store-migration + * ruling: the data layer owns its engine; web-react is shell-only React + * glue): engine products are bare observables — subscribe/getSnapshot/ + * update/set, NO selector hook. Hook synthesis is web-react's (the one + * uSES bridge, cached per source at the binding site). + */ +import { createStore, type StoreApi } from 'zustand/vanilla' +import { subscribeWithSelector } from 'zustand/middleware' +import { shallow } from 'zustand/shallow' +import { produce } from 'immer' +import type { + ActionsDecl, BakedActions, StoreHandle, StoreInstance, StoreSpec, +} from '@deepseek-ai/dsh-client-ui-slots' + +// Store contract types are ui-slots authority; re-exported beside the engine +// so store consumers get one import surface. +export type { + ActionsDecl, BakedActions, BoundActions, StoreFactory, StoreHandle, StoreInstance, StoreSpec, +} from '@deepseek-ai/dsh-client-ui-slots' + +/** Minimal observable snapshot source: Session objects and snapshot stores both satisfy it. */ +export interface ObservableSnapshot { getSnapshot(): T; subscribe(fn: () => void): () => void } + +/** Writable snapshot store (bare data face; React selector hooks are synthesized in web-react). */ +export interface SnapshotStore extends ObservableSnapshot { + /** + * Mutate the state through an immer draft. + * @param mutator - draft mutator. + */ + update(mutator: (draft: T) => void): void + /** + * Replace the state wholesale. + * @param next - next state. + */ + set(next: T): void +} + +/** + * Shallow equality for selector slices (zustand/shallow semantics; travels + * with the engine so hook consumers need no zustand dependency). + * @param a - left value. + * @param b - right value. + * @returns whether the values are shallowly equal. + */ +export function shallowEqual(a: unknown, b: unknown): boolean { + return shallow(a, b) +} + +/** Batches subscriber notification into one flush per animation frame. */ +function rafBatch(notify: () => void): () => void { + // Fall back to microtask batching where rAF is absent (node unit tests); + // both preserve the N-changes=1-notification contract within a tick. + const schedule: (fn: () => void) => void = + typeof requestAnimationFrame === 'function' + ? (fn) => { requestAnimationFrame(() => { fn() }) } + : (fn) => { queueMicrotask(fn) } + let scheduled = false + return () => { + if (scheduled) return + scheduled = true + schedule(() => { + scheduled = false + notify() + }) + } +} + +/** + * Create a snapshot store. + * + * Flush default is 'sync' (controlled inputs need same-tick echo); frame-driven + * stores opt into 'raf', where a frame's worth of updates coalesces into one + * notification. Known raf-mode tradeoff: a component mounting mid-frame reads + * fresh state while existing subscribers hear it next flush — transient + * frame-level skew, same nature as the object layer's microtask batching. + * + * @param init - initial state. + * @param opts - flush mode and opt-in persistence (localStorage, keyed by name). + * @returns the store. + */ +export function createSnapshotStore( + init: T, opts?: { flush?: 'raf' | 'sync'; persist?: { name: string } }): SnapshotStore { + // Immer enters through produce() in update() below (identical semantics to + // the immer middleware without its setState-signature mutator generics). + const withSelector = subscribeWithSelector(() => init) + const api: StoreApi = createStore()(withSelector) + if (opts?.persist) attachPersistence(api, opts.persist.name) + + let subscribe = (fn: () => void) => api.subscribe(fn) + if (opts?.flush === 'raf') { + const listeners = new Set<() => void>() + const flush = rafBatch(() => { for (const fn of [...listeners]) fn() }) + api.subscribe(flush) + subscribe = (fn: () => void) => { + listeners.add(fn) + return () => { listeners.delete(fn) } + } + } + + return { + getSnapshot: () => api.getState(), + subscribe: fn => subscribe(fn), + update: (mutator) => { + // Immer's produce (not setState's partial-merge path) so scalar and + // array roots replace correctly; produce also freezes in dev. + api.setState(produce(api.getState(), (draft) => { mutator(draft as T) }), true) + }, + set: (next) => { + api.setState(devFreeze(next), true) + }, + } +} + +/** + * Whole-value JSON persistence to localStorage. Hand-rolled instead of the + * zustand persist middleware: its write path spreads state into an object + * (`partialize({ ...get() })`), exploding primitive state (a persisted string + * draft becomes {0:'h',1:'e',...}) — not fixable via merge/deserialize options + * because the corruption happens before serialization. Storage failures + * (quota, private mode) only disable persistence, never break the store. + */ +function attachPersistence(api: StoreApi, name: string): void { + // Non-browser runs (node e2e booting the client tree) have no localStorage: + // persistence silently disables — same contract as a storage failure, minus + // the per-store console noise a ReferenceError would produce. + if (typeof localStorage === 'undefined') return + try { + const raw = localStorage.getItem(name) + if (raw !== null) { + api.setState(devFreeze(JSON.parse(raw) as T), true) + } + } catch (error) { + console.error(`snapshot store '${name}' rehydration failed:`, error) + } + api.subscribe((state) => { + try { + localStorage.setItem(name, JSON.stringify(state)) + } catch (error) { + console.error(`snapshot store '${name}' persistence failed:`, error) + } + }) +} + +/** Deep-freeze wholesale-set state outside production: set() bypasses immer's freeze. */ +function devFreeze(value: T): T { + if (process.env.NODE_ENV === 'production') return value + deepFreeze(value) + return value +} + +function deepFreeze(value: unknown): void { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return + Object.freeze(value) + for (const key of Reflect.ownKeys(value)) { + deepFreeze((value as Record)[key]) + } +} + +// ---- defineStore shell (slot terminal design §4) ---- +// The type authority is ui-slots' store family (create(scopeKey?) and +// clearPersisted() included); this module houses only the engine-backed +// implementation. The one engine-side widening left: instances expose the +// raw engine store for framework/test surfaces. + +/** A live engine instance: the contract instance plus the raw engine store. */ +export interface EngineStoreInstance> extends StoreInstance { + /** The underlying engine store (framework/test surface; components never see it). */ + readonly store: SnapshotStore +} + +/** The engine-backed handle: create() narrowed to the engine instance. */ +export interface EngineStoreHandle> extends StoreHandle { + /** + * Construct a live engine instance (see the contract JSDoc on + * {@link StoreHandle.create} for scopeKey/persist semantics). + * + * Known boundary: the persist key is the storage identity, so multiple live + * instances created under the same resolved key share (and cross-pollute) + * one localStorage entry. Instance uniqueness per key is the caller's + * responsibility — production is safe because the framework caches one + * instance per handle x scope key; tests wanting isolation use distinct + * scope keys or persist-free declarations (multi-create freedom is a + * feature there, so create() deliberately does not dedupe or throw). + * @param scopeKey - session id for session-scope instances; omitted for root scope. + * @returns the engine instance. + */ + create(scopeKey?: string): EngineStoreInstance +} + +/** + * Declare a store: initial state, optional persistence, and the full write + * set as pure draft mutators. The returned handle is the registration + * currency of the store seat — its identity keys instance sharing. Satisfies + * ui-slots' DefineStore contract (the handle/instance are the engine-extended + * subtypes). + * + * The `A & ActionsDecl` actions position is load-bearing: T resolves from + * `init` in the first inference round, and the intersection then contextually + * types each mutator's draft parameter (context-sensitive functions defer), + * so call sites write `(d, x: X) => { ... }` with no draft annotation. If a + * future TS version breaks this single-literal inference, the design's + * documented fallback is currying (`defineStore(init).actions({...})`). + * @param decl - init lambda (fresh state per instance), optional persist key, actions table. + * @returns the store handle. + */ +export function defineStore>( + decl: StoreSpec & { actions: A & ActionsDecl }): EngineStoreHandle { + return { + spec: decl, + create(scopeKey?: string): EngineStoreInstance { + const persistKey = decl.persist === undefined + ? undefined + : scopeKey === undefined ? decl.persist : `${decl.persist}.${scopeKey}` + const store = createSnapshotStore( + decl.init(), + persistKey !== undefined ? { persist: { name: persistKey } } : undefined) + const actions = {} as Record void> + for (const key of Object.keys(decl.actions)) { + const mutate = decl.actions[key] as (draft: T, ...params: unknown[]) => void + actions[key] = (...params: unknown[]) => { store.update((draft) => { mutate(draft, ...params) }) } + } + return { + actions: actions as BakedActions, + getSnapshot: () => store.getSnapshot(), + subscribe: fn => store.subscribe(fn), + store, + clearPersisted: () => { + if (persistKey === undefined || typeof localStorage === 'undefined') return + try { + localStorage.removeItem(persistKey) + } catch { + // Storage failures (private mode, quota teardown races) only skip + // cleanup — the same non-fatal contract as attachPersistence. + } + }, + } + }, + } +} diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 6c012fa410..77b51a68b5 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,30 +1,40 @@ /** * 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 { SnapshotStore, UseSession } from '@deepseek-ai/dsh-client-web-react' +import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' +import type { SnapshotStore } from './contract/store.ts' 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' -export { SessionManager } from './sessions/manager.ts' -export type { SessionListSnapshot } from './sessions/manager.ts' -export { Session, PAGE_MESSAGES } from './sessions/session.ts' -export type { SessionListEntry } from './sessions/lineage.ts' +// The snapshot-store engine lives here since the store migration (the data +// layer owns its substrate; web-react is React glue only). The './client' +// main export is the single serving door — no store subpath. +export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts' +export type { + EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, +} from './contract/store.ts' export type { AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot, - OpenState, PartialAssistant, PendingInteraction, PromptError, RunningToolCall, SteeringMessageNode, + PendingInteraction, RunningToolCall, SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' @@ -41,11 +51,8 @@ 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 +export type UseConversationSession = SnapshotSelectorHook /** * One tool call as the chat flow renders it: still-running (spinner card) or @@ -54,6 +61,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..81148a7dfc 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 '../contract/store.ts' import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts' export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts' diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 2b4cf9677e..ec63b8f70d 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,9 @@ */ 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' +import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots' +import type { SnapshotStore } from '../contract/store.ts' +import { createSnapshotStore } from '../contract/store.ts' import { SessionManager } from './manager.ts' import type { Session } from './session.ts' @@ -28,8 +31,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 +76,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 +108,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 +166,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 +209,14 @@ 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 }, + // Bare source form (store migration): the Session object IS the + // observable; the React side binds the useSession hook per cell. + cell: { sessionId: id, session }, } this.scopes.set(id, record) return record @@ -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/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index b13a1a5bee..44ec4cdd50 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -10,8 +10,7 @@ import type { SessionId, ToolEventView, } from '@deepseek-ai/dsh-client-connection/client' import { transportError } from '@deepseek-ai/dsh-client-connection/client' -import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import type { ObservableSnapshot } from '../contract/store.ts' import type { ConversationNode, ConversationSnapshot, OpenState, PendingInteraction, PromptError, RunningToolCall, } from './conversation.ts' @@ -22,11 +21,13 @@ import { PartialAccumulator } from './partial.ts' /** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */ export const PAGE_MESSAGES = 50 -/** Per-session state owner: event window + fold + partial, snapshot out via uSES (see the web client architecture RFC). */ +/** + * Per-session state owner: event window + fold + partial, snapshot out via + * subscribe/getSnapshot (see the web client architecture RFC). Bare source + * only (store migration): the React machinery binds the per-cell useSession + * hook at its own seam — no selector hook member lives on the data layer. + */ export class Session implements ObservableSnapshot { - /** Typed selector hook bound to this instance (the SessionBinding `useSession` source). */ - readonly useSelector: SnapshotSelectorHook = bindSnapshotSelector(this) - // ---- Window and derived state (all private; the snapshot is the only read surface) ---- private events: SessionEvent[] = [] /** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view). diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index 5b58670bd4..700026df52 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -1,22 +1,84 @@ /** - * 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 { + OwnerOf, 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 +} + +/** 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 +89,92 @@ 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. The typed face IS the core's register + * (both overloads reused verbatim — one authority, no structural copy; + * see SlotCore.register for children declaration, store seat, inject + * face, load-time validation, and the 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. + * + * Declared here, implemented by prototype assignment below the class: it + * MUST stay a prototype method (never an instance arrow) — the cordis + * service proxy binds `this.ctx` to the CALLER's context at call time, + * which is what routes the effect (and the unload cascade) into the + * caller's fiber. An arrow property would freeze `this` to the service's + * own root ctx and silently break per-plugin disposal. */ - define(key: K, spec: SlotSpec): () => void { - // 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()') + declare readonly register: SlotCore['register'] + + /** + * 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. + */ + 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()') } /** - * 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. + * 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. */ - 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()') + 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) } /** - * Snapshot entries for a key. - * @param key - SlotMap key. - * @returns registered entries (stable reference between mutations). + * 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. */ - entries(key: K): readonly SlotEntry[] { + 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 +182,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 +201,114 @@ export class SlotsService extends Service { return this._core.getVersion(key) } - /** The wrapped pure core (web-react's scopedSlots outlet reads 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) } } + +// register's implementation (prototype assignment pairs with the `declare` +// inside the class — see its JSDoc for why it must live on the prototype). +// Element access reaches the private _register legally and keeps it a +// TS-visible read. +;(SlotsService.prototype as { register: (options: object, component: unknown) => () => void }).register + = function register(this: SlotsService, rawOptions: object, component: unknown): () => void { + // The core's overloads proved the shares; the implementation works on + // the erased view (same pattern as the core's own implementation arm). + 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['_register'](options, component), 'slots.register()') + } diff --git a/packages/client/web-react/src/env.d.ts b/packages/client/runtime/src/env.d.ts similarity index 100% rename from packages/client/web-react/src/env.d.ts rename to packages/client/runtime/src/env.d.ts diff --git a/packages/client/runtime/tests/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/client-loader-bundle.e2e.ts b/packages/client/runtime/tests/client-loader-bundle.e2e.ts deleted file mode 100644 index b8932b0c62..0000000000 --- a/packages/client/runtime/tests/client-loader-bundle.e2e.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Real-bundle smoke: the actual tsdown client bundle of ui-layout runs - * through the loader chain (execute → handoff → factory(require) → apply → - * export re-registration). Skips when the bundle is not built (lib/client.js is a - * build product; `pnpm --filter @deepseek-ai/dsh-client-ui-layout build`). - */ -import { readFileSync } from 'node:fs' -import { createRequire } from 'node:module' -import { Context } from 'cordis' -import { afterEach, describe, expect, it } from 'vitest' -import * as uiSlots from '@deepseek-ai/dsh-client-ui-slots' -import * as webReact from '@deepseek-ai/dsh-client-web-react' -import { createClientLoader } from '../src/client/loader/index.ts' -import type { ClientPluginHandoff } from '../src/client/loader/index.ts' -import { SessionsService } from '../src/client/sessions/service.ts' -import { SlotsService } from '../src/client/slots.ts' -import { FakeApiClient } from './fake-api.ts' - -const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout' - -type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; window?: unknown } - -afterEach(() => { - delete (globalThis as Win).DSHClientProxy - delete (globalThis as Win).window -}) - -function readLayoutBundle(): string | undefined { - try { - const require = createRequire(import.meta.url) - return readFileSync(require.resolve(`${LAYOUT_ID}/client`), 'utf8') - } catch { - return undefined - } -} - -describe('real tsdown bundle through the loader', () => { - const code = readLayoutBundle() - - it.skipIf(code === undefined)('loads ui-layout lib/client.js: handoff, DI require, apply, export surface', async () => { - // The bundle banner addresses window.DSHClientProxy; node has no window — - // alias it to globalThis so the loader-installed proxy is reachable. - ;(globalThis as Win).window = globalThis - const ctx = new Context() - // The layout apply consumes the slots + sessions services; the real chain - // loads the runtime bundle first — stand both up directly here. - ctx.plugin(SlotsService) - await ctx.fiber.await() - new SessionsService(ctx, new FakeApiClient()) - const loader = createClientLoader({ - ctx, - // The real bundle externals resolved from the seeded table. React is a - // type-only import in the layout bundle today, but jsx-runtime is real. - modules: { - 'react': await import('react'), - 'react/jsx-runtime': await import('react/jsx-runtime'), - '@deepseek-ai/dsh-client-ui-slots': uiSlots, - '@deepseek-ai/dsh-client-web-react': webReact, - }, - boot: { plugins: [{ id: LAYOUT_ID, url: `/plugins/${LAYOUT_ID}/client.js`, inject: [] }] }, - fetchBundle: () => Promise.resolve(code as string), - // node has no DOM: evaluate the bundle body directly (same synchronous - // handoff contract as the