mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
refactor(client): move web rendering into a dynamic plugin
This commit is contained in:
@@ -22,23 +22,23 @@ Both ends run cordis. The host is a cordis plugin tree; the browser runs a secon
|
||||
│ ├ GET /plugins/<id>/client.js │ │ │ ui-theme/i18n(fetch bundle,boot 预拉) │
|
||||
│ └ GET / 注入 __DSH_BOOT__ 图 │ │ ├ lazy entries: layout/sidebar/ │
|
||||
│ │ │ │ conversation/trajectory(fetch bundle,按需) │
|
||||
└────────────────────────────────┘ │ ├ app-shell 伪行(壳内静态注册,同一治理) │
|
||||
└────────────────────────────────┘ │ ├ render-service(fetch bundle,React 根) │
|
||||
│ └ session scope ×N(观看驱动,惰性建) │
|
||||
│ React: loading 页 → settled → 整 UI 一次成型 │
|
||||
│ DOM loading 页 → settled → React UI 一次成型 │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## The client cordis tree and the loading chain
|
||||
|
||||
The loading chain — the two package kinds (plain vs dsh.client plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md). The load-bearing facts for this document: the browser boots the same vendored `@cordisjs/plugin-loader` as the host with a client module system (`ctx.modules`, `packages/client/modules`) filling its `internal` contract; every unit with product behavior is an entry in the host-authored `__DSH_BOOT__` graph — every production plugin package (infrastructure included) carries the `dsh.client` declaration and arrives as a fetched `./client` tsdown closure bundle, `immediately` rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute `window.__ModuleLoader__.load({ id, factory })` and their `require` is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); plugin CSS is inlined in the bundle and injected as `<style data-plugin="<id>">` at materialization (CSS Modules hashing + ownership tag = isolation, removal on reload); hot reload is live in dev graphs — the webserver stat-polls the bundles it serves and broadcasts `rebuilt` SSE frames, and the `client-hmr` plugin swaps one fiber per frame. The settled flip (`loader.await()` + an all-ACTIVE sweep) still switches the shell from the loading page to the real UI in one pass — settled means every entry is created and every fiber reached ACTIVE, with FAILED/PENDING fibers listed loud; there is no partial-availability mode (progressive rendering is deferred work).
|
||||
The loading chain — the two package kinds (plain vs dsh.client plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md). The load-bearing facts for this document: the browser boots the same vendored `@cordisjs/plugin-loader` as the host with a client module system (`ctx.modules`, `packages/client/modules`) filling its `internal` contract; every unit with product behavior is an entry in the host-authored `__DSH_BOOT__` graph — every production plugin package (infrastructure included) carries the `dsh.client` declaration and arrives as a fetched `./client` tsdown closure bundle, `immediately` rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute `window.__ModuleLoader__.load({ id, factory })` and their `require` is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); global styles and CSS Modules are inlined in their owning plugin bundle and injected as `<style data-plugin="<id>">` at materialization (CSS Modules also receive hashed names; ownership tags make reload removal possible); hot reload is live in dev graphs — the webserver stat-polls the bundles it serves and broadcasts `rebuilt` SSE frames, and the `client-hmr` plugin swaps one fiber per frame. After `loader.await()` and an all-ACTIVE sweep, the framework-free kernel calls the dynamic render service's `ctx.appShell.mount(container)` once — every entry is created and every fiber reached ACTIVE, with FAILED/PENDING fibers listed loud; there is no partial-availability mode (progressive rendering is deferred work).
|
||||
|
||||
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
|
||||
|
||||
The slot system has its own note — the [slot system standard](2026-07-22-slot-type-chain-implementation.md) — and this document defers to it entirely. The one-paragraph summary for orientation: the shell renders only `'root'`; a plugin composes UI through a single `register` call that occupies a slot, declares+authorizes its child slots (`children` spec object), declares its store, and injects its business face; component props arrive in four auto-derived shares (`PropsRuntime<K>` / `PropsRenderSlots<S>` / `PropsStore<H>` / inject), each from its single source of truth. `SlotMap` declaration merging is the type authority and entries carry only the owner share ("whoever injects it, owns its type"); every rendered entry sits in a per-entry error boundary.
|
||||
The slot system has its own note — 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: render-service renders only `'root'`; a plugin composes UI through a single `register` call that occupies a slot, declares+authorizes its child slots (`children` spec object), declares its store, and injects its business face; component props arrive in four auto-derived shares (`PropsRuntime<K>` / `PropsRenderSlots<S>` / `PropsStore<H>` / inject), each from its single source of truth. `SlotMap` declaration merging is the type authority and entries carry only the owner share ("whoever injects it, owns its type"); every rendered entry sits in a per-entry error boundary.
|
||||
|
||||
Implementation homes: registry core and the props-share types in `packages/client/ui-slots`, outlet/renderer/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`, and application-level installation and mounting in `packages/client/render-service`.
|
||||
|
||||
## Services and scope addressing
|
||||
|
||||
@@ -75,18 +75,18 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──
|
||||
- **ConversationNodeAssembler** (`runtime/src/client/conversation/`): the Session-owned incremental engine runs independently registered Definitions over raw events. `match(event)` selects `(kind, id)` without Context scans; start/update build Definition state; engine-computed Locations carry Turn/Step closure; backward Context reads record dependencies repaired by later prepends; `buildViewNode(target)` materializes only dirty Contexts. The Chat builder preserves structural order and per-key value identity, `useSession` selectors isolate consumption, and Assistant token publication coalesces to one animation frame. The [Conversation Node decision](2026-08-09-client-conversation-node-assembly.md) owns assembly, while [Tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) owns recursive Tool rendering.
|
||||
- **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; Web carriage uses HTTP POST for the two client→server quadrants and [one WebSocket per logical stream](2026-08-04-websocket-downlink-carrier.md) for the two server→client quadrants, while the client class family remains the layering note's territory.
|
||||
|
||||
## The React face (`packages/client/web-react`)
|
||||
## The React face (`packages/client/web-react` and `packages/client/render-service`)
|
||||
|
||||
The glue package is the whole ctx↔React boundary; components stay framework-free.
|
||||
web-react is the static ctx↔React renderer adapter, while the dynamic render-service plugin owns its application-level installation, root mount, and title projection. Business components stay framework-free.
|
||||
|
||||
- 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<T>` (`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.
|
||||
- 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<T>` (`getSnapshot`/`subscribe`) — a Session object and a snapshot store both satisfy it. Business plugin packages depend on runtime and ui-slots only; render-service is the sole dynamic consumer that installs web-react's application renderer.
|
||||
- `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.
|
||||
|
||||
## Directory shape
|
||||
|
||||
Client packages live under `packages/client/*`, with `apps/web` as the thin Vite application over the shell's boot export. Plugin packages keep their browser half under `src/client/`; **every build artifact lands in `lib/`** — the node half as `lib/index.js`/`lib/invariant.js`, the browser bundle as `lib/client.js` (the shared tsdown client preset emits both; there is no `dist/` directory, and `exports["./client"]` points at `./lib/client.js`). `ui-slots`, web-react, and runtime form the infrastructure direction; feature plugins cooperate through services and slots rather than importing presentation implementations.
|
||||
Client packages live under `packages/client/*`, with `apps/web` as the thin Vite application over the shell's boot export. Plugin packages keep their browser half under `src/client/`; **every build artifact lands in `lib/`** — the node half as `lib/index.js`/`lib/invariant.js`, the browser bundle as `lib/client.js` (the shared tsdown client preset emits both; there is no `dist/` directory, and `exports["./client"]` points at `./lib/client.js`). `ui-slots`, web-react, runtime, and render-service form the infrastructure direction; feature plugins cooperate through services and slots rather than importing presentation implementations.
|
||||
|
||||
A multi-domain plugin package additionally splits its client half by future package boundaries — ui-conversation is the exemplar:
|
||||
|
||||
|
||||
@@ -22,23 +22,23 @@ Status: implemented
|
||||
│ ├ GET /plugins/<id>/client.js │ │ │ ui-theme/i18n(fetch bundle,boot 预拉) │
|
||||
│ └ GET / 注入 __DSH_BOOT__ 图 │ │ ├ lazy entries: layout/sidebar/ │
|
||||
│ │ │ │ conversation/trajectory(fetch bundle,按需) │
|
||||
└────────────────────────────────┘ │ ├ app-shell 伪行(壳内静态注册,同一治理) │
|
||||
└────────────────────────────────┘ │ ├ render-service(fetch bundle,React 根) │
|
||||
│ └ session scope ×N(观看驱动,惰性建) │
|
||||
│ React: loading 页 → settled → 整 UI 一次成型 │
|
||||
│ DOM loading 页 → settled → React UI 一次成型 │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## client cordis 树与装载链
|
||||
|
||||
装载链——两类包(普通包 vs dsh.client 插件)、模块系统/插件治理器之分、host 独家撰写的带修订号 entry 图之上的双阶段 boot、热重载——归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有。本篇赖以立足的事实:浏览器启动与 host 相同的 vendored `@cordisjs/plugin-loader`,由 client 模块系统(`ctx.modules`,`packages/client/modules`)填上其 `internal` 约定;凡带产品行为的单元都是 host 独家撰写的 `__DSH_BOOT__` 图里的 entry——每个生产插件包(含基础设施)都携带 `dsh.client` 声明、以 fetch 到达的 `./client` tsdown 闭包 bundle 供给,`immediately` 行的差别仅在 boot 第一阶段预取,而普通包(react 家族、cordis、尚未升格的库)保持打进壳、已播种、对图不可见;bundle 执行 `window.__ModuleLoader__.load({ id, factory })`,其 `require` 由 lazy CJS 模块表应答(种子词条 + 已登记工厂,首次 require 时物化并记忆化——跨插件值 import 是构建错误,协作走 cordis 服务);插件 CSS 内联在 bundle 里、物化时注入为 `<style data-plugin="<id>">`(CSS Modules 哈希 + 归属标记 = 隔离,重载时移除);热重载已在 dev 图落地——webserver 对自己供给的 bundle 做 stat 轮询并广播 `rebuilt` SSE 帧,`client-hmr` 插件每帧换掉一个 fiber。settled 翻转(`loader.await()` + 一次全 ACTIVE 扫描)依旧让壳从 loading 页一次切换到真 UI——settled 意味着每个 entry 已创建、每个 fiber 都到达 ACTIVE,FAILED/PENDING 的 fiber 被大声列出;不存在部分可用模式(渐进渲染为后置工作)。
|
||||
装载链——两类包(普通包 vs dsh.client 插件)、模块系统/插件治理器之分、host 独家撰写的带修订号 entry 图之上的双阶段 boot、热重载——归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有。本篇赖以立足的事实:浏览器启动与 host 相同的 vendored `@cordisjs/plugin-loader`,由 client 模块系统(`ctx.modules`,`packages/client/modules`)填上其 `internal` 约定;凡带产品行为的单元都是 host 独家撰写的 `__DSH_BOOT__` 图里的 entry——每个生产插件包(含基础设施)都携带 `dsh.client` 声明、以 fetch 到达的 `./client` tsdown 闭包 bundle 供给,`immediately` 行的差别仅在 boot 第一阶段预取,而普通包(react 家族、cordis、尚未升格的库)保持打进壳、已播种、对图不可见;bundle 执行 `window.__ModuleLoader__.load({ id, factory })`,其 `require` 由 lazy CJS 模块表应答(种子词条 + 已登记工厂,首次 require 时物化并记忆化——跨插件值 import 是构建错误,协作走 cordis 服务);全局样式与 CSS Modules 都内联在其持有插件的 bundle 中,物化时注入为 `<style data-plugin="<id>">`(CSS Modules 还会取得哈希类名;归属标签使重载时移除成为可能);热重载已在 dev 图落地——webserver 对自己供给的 bundle 做 stat 轮询并广播 `rebuilt` SSE 帧,`client-hmr` 插件每帧换掉一个 fiber。`loader.await()` 与全 ACTIVE 扫描完成后,不依赖框架的内核会调用动态渲染服务的 `ctx.appShell.mount(container)` 一次——此时每个 entry 已创建、每个 fiber 都到达 ACTIVE,FAILED/PENDING 的 fiber 被大声列出;不存在部分可用模式(渐进渲染为后置工作)。
|
||||
|
||||
类型宇宙在聚合层拆分——`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 体系:页面怎么拼
|
||||
|
||||
slot 体系有自己的笔记——[slot 体系标准](2026-07-22-slot-type-chain-implementation.md)——本文整体移交给它。此处只留一段定位摘要:壳只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——占用 slot、声明并授权子 slot(`children` spec 对象)、声明 store、注入业务面;组件 props 分四份额自动推导到达(`PropsRuntime<K>` / `PropsRenderSlots<S>` / `PropsStore<H>` / inject),各有唯一真源。`SlotMap` 声明合并仍是类型权威,entry 只携带 owner 份额(「谁注入的,类型归谁」);每个被渲染的注册项都在 per-entry 错误边界之内。
|
||||
slot 体系有自己的笔记——[slot 体系标准](2026-07-22-slot-type-chain-implementation.md)——本文整体移交给它。此处只留一段定位摘要:render-service 只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——占用 slot、声明并授权子 slot(`children` spec 对象)、声明 store、注入业务面;组件 props 分四份额自动推导到达(`PropsRuntime<K>` / `PropsRenderSlots<S>` / `PropsStore<H>` / inject),各有唯一真源。`SlotMap` 声明合并仍是类型权威,entry 只携带 owner 份额(「谁注入的,类型归谁」);每个被渲染的注册项都在 per-entry 错误边界之内。
|
||||
|
||||
实现的家:注册表核心与 props 份额类型在 `packages/client/ui-slots`,出口组件/渲染器/uSES 桥在 `packages/client/web-react`。
|
||||
实现的家:注册表核心与 props 份额类型在 `packages/client/ui-slots`,出口组件/渲染器/uSES 桥在 `packages/client/web-react`,应用级安装与挂载在 `packages/client/render-service`。
|
||||
|
||||
## 服务与 scope 寻址
|
||||
|
||||
@@ -75,18 +75,18 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──
|
||||
- **ConversationNodeAssembler**(`runtime/src/client/conversation/`):Session 拥有的增量引擎在原始事件上运行各自独立注册的 Definition。`match(event)` 无须扫描 Context 即可选出 `(kind, id)`;start/update 构造 Definition state;引擎计算的 Location 携带 Turn/Step 关闭信息;向前查询 Context 时记录依赖,并由后续 prepend 修复;`buildViewNode(target)` 只物化 dirty Context。Chat builder 保留结构顺序和 per-key value identity,`useSession` selector 负责消费隔离,Assistant token 发布则合并到每个 animation frame 一次。[Conversation Node 决策](2026-08-09-client-conversation-node-assembly.md)拥有组装边界,[Tool 展示所有权](2026-08-08-client-tool-presentation-ownership.md)拥有 Tool 递归渲染。
|
||||
- **ConnectionController**(在 `packages/client/connection`):开 mux/host 双流、for-await 泵入,代际围栏之内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sinks 单向注入(Controller 不认识 Session)。重连 = 重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层只面向 `IApiClient`;Web 承载以 HTTP POST 载两个 client→server 象限、以[每逻辑流一条 WebSocket](2026-08-04-websocket-downlink-carrier.md)载两个 server→client 象限,客户端类族归分层笔记属地。
|
||||
|
||||
## React 面(`packages/client/web-react`)
|
||||
## React 面(`packages/client/web-react` 与 `packages/client/render-service`)
|
||||
|
||||
胶水包就是整条 ctx↔React 边界;组件保持零框架依赖。
|
||||
web-react 是静态 ctx↔React 渲染适配器,动态 render-service 插件持有其应用级安装、根挂载与标题投影。业务组件保持零框架依赖。
|
||||
|
||||
- 快照 store 引擎**住 runtime 包**(zustand vanilla + 草稿式更新,缺省 `flush: 'sync'`,可选 `'raf'` 合批,可选整值 localStorage 持久化,dev 深冻结——全部从 `runtime` 的 `./client` 主出口导出,无子路径):store 产物是裸的可观察源,不带任何钩子成员。插件只经 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 的 `defineStore` 声明触及引擎。web-react 在绑定处(`bindSnapshotSelector`,按源缓存)从 React 消费的唯一数据约定合成每个钩子:`ObservableSnapshot<T>`(`getSnapshot`/`subscribe`)——Session 对象与快照 store 同构满足它。业务插件包只依赖 runtime 与 ui-slots;web-react 是仅壳可用的胶水。
|
||||
- 快照 store 引擎**住 runtime 包**(zustand vanilla + 草稿式更新,缺省 `flush: 'sync'`,可选 `'raf'` 合批,可选整值 localStorage 持久化,dev 深冻结——全部从 `runtime` 的 `./client` 主出口导出,无子路径):store 产物是裸的可观察源,不带任何钩子成员。插件只经 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 的 `defineStore` 声明触及引擎。web-react 在绑定处(`bindSnapshotSelector`,按源缓存)从 React 消费的唯一数据约定合成每个钩子:`ObservableSnapshot<T>`(`getSnapshot`/`subscribe`)——Session 对象与快照 store 同构满足它。业务插件包只依赖 runtime 与 ui-slots;render-service 是唯一安装 web-react 应用渲染器的动态消费方。
|
||||
- `bindSnapshotSelector(source)`:把一个源绑定为经 uSES-with-selector 的带类型 selector 钩子。uSES 约定四条按构造成立:getSnapshot 恒返缓存引用;subscribe 是绑定期闭包(引用永稳);纯 CSR 不传 server snapshot;相等性缺省 `Object.is`,按调用可选 `shallowEqual`。
|
||||
- `useInvoke(fn)`:把异步动作包成引用恒定的触发器加 pending 标志;pending 走每个钩子的外部 store 经 uSES 读出(渲染路径零 setState),并发调用计数,invoke 引用永不变。
|
||||
- 相等性协议,全链一致:生产端结构共享;消费方以 `Object.is` 或 `shallowEqual` 短路;`React.memo` 浅比较。深比较全链禁止。
|
||||
|
||||
## 目录形态
|
||||
|
||||
Client 包位于 `packages/client/*`,`apps/web` 是壳 boot 导出之上的薄 Vite 应用。插件包的浏览器半边在 `src/client/` 下;**一切构建产物落 `lib/`**——node 半边为 `lib/index.js`/`lib/invariant.js`,浏览器 bundle 为 `lib/client.js`(共享 tsdown client 预设两者皆出;无 `dist/` 目录,`exports["./client"]` 指向 `./lib/client.js`)。`ui-slots`、web-react 与 runtime 构成基础设施方向;功能插件通过服务与 slot 协作,不导入展示实现。
|
||||
Client 包位于 `packages/client/*`,`apps/web` 是壳 boot 导出之上的薄 Vite 应用。插件包的浏览器半边在 `src/client/` 下;**一切构建产物落 `lib/`**——node 半边为 `lib/index.js`/`lib/invariant.js`,浏览器 bundle 为 `lib/client.js`(共享 tsdown client 预设两者皆出;无 `dist/` 目录,`exports["./client"]` 指向 `./lib/client.js`)。`ui-slots`、web-react、runtime 与 render-service 构成基础设施方向;功能插件通过服务与 slot 协作,不导入展示实现。
|
||||
|
||||
多域插件包的 client 半边还按未来包边界再拆——ui-conversation 即样板:
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ The page is composed at runtime from independently loaded plugins, so the UI nee
|
||||
|
||||
## Decision
|
||||
|
||||
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.**
|
||||
One sentence: **the render-service 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.**
|
||||
|
||||
### 'root' is the only a-priori slot
|
||||
|
||||
`SlotRegistry` (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).
|
||||
`SlotRegistry` (client runtime) declares `'root'` at construction — single/root, `owner: {}` — and its `SlotMap` merge lives in the runtime package. The render-service'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).
|
||||
|
||||
### register is the single API; children = declaration + authorization + runtime spec
|
||||
|
||||
|
||||
+2
-2
@@ -12,11 +12,11 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
一句话:**壳只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——这一次调用同时占用 slot、声明并授权子 slot、声明 store、注入业务面;组件是纯函数,props 分四份额到达,每一份额都从各自唯一的真源自动推导。**
|
||||
一句话:**render-service 只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——这一次调用同时占用 slot、声明并授权子 slot、声明 store、注入业务面;组件是纯函数,props 分四份额到达,每一份额都从各自唯一的真源自动推导。**
|
||||
|
||||
### 'root' 是唯一的先验 slot
|
||||
|
||||
`SlotRegistry`(client 运行时)在构造时声明 `'root'`——single/root、`owner: {}`——其 `SlotMap` 合并声明位于运行时包。壳的全部装配就是 `ctx.slots.renderSlot('root', {})`:唯一的 ctx 级渲染入口;传任何其他键、渲染器未安装、root 无人注册,一律大声失败(无 fallback)。
|
||||
`SlotRegistry`(client 运行时)在构造时声明 `'root'`——single/root、`owner: {}`——其 `SlotMap` 合并声明位于运行时包。render-service 的全部装配就是 `ctx.slots.renderSlot('root', {})`:唯一的 ctx 级渲染入口;传任何其他键、渲染器未安装、root 无人注册,一律大声失败(无 fallback)。
|
||||
|
||||
### register 是唯一 API;children = 声明+授权+运行时 spec
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ The first-generation client loader (`createClientLoader`) hand-wrote both layers
|
||||
What makes a package a plugin? One rule: **a package is a plugin package once its consumption is cordis dependency injection; until then it is a plain package.** How code reaches the page is not part of the taxonomy — arrival follows from the kind instead of defining it.
|
||||
|
||||
- **Plain packages** are the absolute base the module system itself needs, plus libraries not yet converted to DI: the react family, cordis, `@deepseek-ai/dsh-client-modules` (the module system itself — it can never be a plugin, because modules precede all modules), the web shell kernel, and — for now — ui-slots, web-react, ui-primitives. Plain packages are shell-bundled, seeded into the module table, and invisible to the host graph.
|
||||
- **Plugin packages** are everything else. Each one carries a `dsh.client` manifest declaration (`{ platform, inject, immediately? }`) and one uniform shape: the shared tsdown preset emits `lib/client.js`, and `exports["./client"]` points at that bundle. Each is a governed entry of the host-authored graph. The current set is connection, runtime, ui-theme, i18n, hmr (dev graphs only), ui-layout, ui-sidebar, ui-conversation, ui-model-selector, ui-user-questions, and ui-trajectory.
|
||||
- **Plugin packages** are everything else. Each one carries a `dsh.client` manifest declaration (`{ platform, inject, immediately? }`) and one uniform shape: the shared tsdown preset emits `lib/client.js`, and `exports["./client"]` points at that bundle. Each is a governed entry of the host-authored graph, including infrastructure such as connection, runtime, ui-theme, i18n, hmr, and render-service as well as feature packages such as ui-layout, ui-conversation, and ui-attachment.
|
||||
|
||||
The manifest owns the package's loading contract: its `inject` dependency edges, plus the optional `immediately` prefetch mark (absent means lazy). The composing app owns only the roster.
|
||||
|
||||
@@ -42,13 +42,13 @@ Four edge rules govern imports across the two kinds. None of them depends on any
|
||||
- **Plugin ↔ plugin value imports are a build error.** This holds regardless of either side's `immediately` declaration — the rule must not depend on a mark someone can flip. Cooperation goes through cordis inject/services. `import type` is exempt; the type chain is untouched. This rule is why `scopeOf` is a `SessionRuntime` method and why `transportError` lives in `dsh-host-apiproxy`'s wire layer (its `RpcResult` home, inline-safe).
|
||||
- **Plugin → plain package value imports are externals**, judged against the platform list. That list is one constant in the shell (`platform.ts`: react family, cordis, ui-slots, web-react, ui-primitives), imported by both the tsdown preset (for the external judgement) and `seed.ts` (for the table warm-up). One constant, two consumers — the hand-sync drift class stays dead.
|
||||
- **The purity gate covers every plugin package.** Its three branches: platform imports become externals; INLINE_SAFE wire layers are inlined; any other workspace leak is a build error. The uniform bundle shape is what makes this coverage total — every plugin builds through the same preset, so no package can sit outside the gate.
|
||||
- **The shell is self-sufficient.** The kernel (boot + loading page) value-imports no plugin package; its status stores are hand-rolled. The fail-loud presentation must not depend on the system whose failure it reports.
|
||||
- **The shell is self-sufficient.** Apart from statically adopting the modules package that constructs the module system itself, the kernel value-imports no plugin package. Its loading and failure page uses plain DOM, local state, and local CSS fallbacks, so the fail-loud presentation does not depend on the render service whose failure it may report.
|
||||
|
||||
### One module system, one plugin governor
|
||||
|
||||
The browser mirrors the host's division of labor. `dsh-client-modules` (`ClientModuleSystem`) takes the module-system seat that Node's internal ESM loader holds host-side; the same vendored `@cordisjs/plugin-loader` keeps the governance seat on both sides. The line between them in one sentence: **the module system owns module identity and bytes — how code arrives, registers, and becomes an exports; the Loader owns plugin lifecycle — when a plugin mounts, what it waits for, and how it is torn down.**
|
||||
|
||||
`ClientModuleSystem` is a lazy CJS table. Executing a bundle only **registers** its factory — the bundle calls `window.__ModuleLoader__.load({ id, factory })` and nothing else happens. Every module body side effect, CSS injection included, lives inside the factory closure and runs at materialization: the first `require`/import of that id, memoized after that. A factory that requires a registered-but-unmaterialized sibling materializes it recursively, so no sort order exists anywhere. When asked to import an id, the table resolves through a fixed branch order: seed word → memoized record → static registration (shell-own modules, e.g. app-shell) → registered factory → graph-row external classic-script load → loud throw. That final throw is the runtime mirror of the build-time purity gate. The system also keeps per-module bookkeeping — owned `<style data-plugin>` tag ids, observed require edges — and exposes the two verbs HMR needs: `prefetch(id)` (load the script and register its factory; concurrent calls share one in-flight task) and `invalidate(id)` (drop the factory and record so the next arrival reloads it).
|
||||
`ClientModuleSystem` is a lazy CJS table. Executing a bundle only **registers** its factory — the bundle calls `window.__ModuleLoader__.load({ id, factory })` and nothing else happens. Every module body side effect, CSS injection included, lives inside the factory closure and runs at materialization: the first `require`/import of that id, memoized after that. A factory that requires a registered-but-unmaterialized sibling materializes it recursively, so no sort order exists anywhere. When asked to import an id, the table resolves through a fixed branch order: seed word → memoized record → static registration (the shell-adopted modules package) → registered factory → graph-row external classic-script load → loud throw. That final throw is the runtime mirror of the build-time purity gate. The system also keeps per-module bookkeeping — owned `<style data-plugin>` tag ids, observed require edges — and exposes the two verbs HMR needs: `prefetch(id)` (load the script and register its factory; concurrent calls share one in-flight task) and `invalidate(id)` (drop the factory and record so the next arrival reloads it).
|
||||
|
||||
The vendored Loader consumes the module system through its `internal` contract — the only call site is `tree.import` — and owns everything entry-shaped: entry creation, fiber activation through cordis service waiting (PENDING until injected services exist, cascading when a service is provided), update/refresh, teardown. The governance code is byte-identical to the host side, per vendor policy. Browserization is compile-time mapping in the shell's vite config: a `node:module` stub alias plus `process.*` defines make `ModuleLoader.fromInternal()` return undefined — exactly the empty slot the shell fills. The module system mounts as `ctx.modules`.
|
||||
|
||||
@@ -72,15 +72,15 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
|
||||
|
||||
Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
|
||||
|
||||
**Phase one — the module face.** The shell builds the module system over the graph, then prefetches every `immediately` row in parallel. Prefetch loads the external script and registers its factory only. A single row's prefetch failure is swallowed here: phase two's import retries the load and owns the loud failure, so one bad row cannot mask the others. `immediately` is a prefetch mark — not a barrier, not an identity. The package declares it, the registry carries it into the row. The infrastructure plugins (connection, runtime, ui-theme, i18n, plus hmr) declare it; UI plugins simply arrive on demand.
|
||||
**Phase one — the module face.** The shell builds the module system over the graph, then prefetches every `immediately` row in parallel. Prefetch loads the external script and registers its factory only. A single row's prefetch failure is swallowed here: phase two's import retries the load and owns the loud failure, so one bad row cannot mask the others. `immediately` is a prefetch mark — not a barrier, not an identity. The package declares it, the registry carries it into the row. Infrastructure plugins including connection, runtime, ui-theme, i18n, render-service, and hmr declare it; other UI plugins simply arrive on demand.
|
||||
|
||||
**Phase two — the plugin face.**
|
||||
|
||||
1. The kernel mounts the vendored Loader and injects the module system as `internal` before any entry exists. Ordering matters: `tree.import`'s bare-import fallback must never run in a browser.
|
||||
2. It creates one entry per graph row, plus the app-shell pseudo-row. The assembly entry is shell-own code the kernel appends itself — registered static with the module system, never part of the host graph — so it rides the same entry lifecycle and status coverage as everything else.
|
||||
2. It creates the statically adopted modules bootstrap entry and one entry per graph row. Render assembly is an ordinary host-graph row provided by `dsh-client-render-service`; the kernel appends no assembly pseudo-entry.
|
||||
3. Creation order carries no semantics; fibers activate through service waiting.
|
||||
4. `settled` = every entry created + `loader.await()` quiescent + an all-ACTIVE sweep. The sweep lists each import-failed, FAILED, or PENDING fiber with its missing services. It exists because cordis inject waits have no timeout — the sweep is the fail-loud floor.
|
||||
5. The loading page's boot status is a projection of real fiber states via `internal/status`. The settled flip switches to the real UI in one pass.
|
||||
5. The framework-free loading page projects real fiber states via `internal/status`. After the sweep, the kernel calls `ctx.appShell.mount(container)` and replaces the page with the real UI in one pass.
|
||||
|
||||
### Hot reload: one driver plugin, self-watched bundles
|
||||
|
||||
@@ -109,13 +109,14 @@ The support boundary, stated honestly. Reload is coarse by design: fresh fiber,
|
||||
| react family / cordis | platform singletons | shell-bundled, seeded | plain forever (absolute base) |
|
||||
| vendored `@cordisjs/plugin-loader` | entry governance (same code both sides) | compile-time browserization, kernel-mounted | untouched (vendor policy) |
|
||||
| `dsh-client-modules` | the client module system | lazy CJS table; two-phase boot | plain forever (modules precede modules) |
|
||||
| `dsh-client-web` | shell kernel + AppRoot + app-shell assembly | self-sufficient (hand-rolled status stores, no plugin value imports) | keeps shrinking |
|
||||
| `dsh-client-web` | module/Loader kernel + framework-free boot page | self-sufficient except for the statically adopted modules bootstrap | keeps shrinking |
|
||||
| `dsh-client-render-service` | React root + slot-renderer assembly | dynamic plugin, declares `immediately` | owns the application mount lifecycle |
|
||||
| `dsh-client-ui-slots` | slot registry core | plain, seeded | promote to plugin; receive runtime's slots machinery |
|
||||
| `dsh-client-web-react` | ctx↔React glue | plain, seeded | promote to plugin; renderer install moves into its apply |
|
||||
| `dsh-client-web-react` | ctx↔React renderer adapter | plain, seeded; installed by render-service | promote when its consumers switch to DI |
|
||||
| `dsh-client-ui-primitives` | base components | plain, seeded | promote to plugin (components via slots/services) |
|
||||
| `dsh-client-connection` | wire layer | plugin (`dsh.client` + bundle), declares `immediately` | transport swap (Electron IPC carrier) |
|
||||
| `dsh-client-runtime` | session object layer + slots service + store engine | plugin, declares `immediately` | keeps shrinking toward a pure session object layer |
|
||||
| `dsh-client-ui-theme` | theme tokens/service | plugin, declares `immediately`, plus the `./styles/*` source channel | Theme Registry (separate ruling) |
|
||||
| `dsh-client-ui-theme` | theme tokens/service | plugin, declares `immediately`; global CSS is in its client bundle | Theme Registry (separate ruling) |
|
||||
| `dsh-client-i18n` | I18nService | plugin, declares `immediately` | per-deployment locale composition |
|
||||
| `dsh-client-hmr` | hot reload driver | plugin, declares `immediately` | rollback; reconnect handshake |
|
||||
| ui-layout / ui-sidebar / ui-conversation / ui-trajectory | UI features | plugins, on-demand | conversation domain split; trajectory real implementation |
|
||||
|
||||
+10
-9
@@ -29,7 +29,7 @@ host 侧,cordis 插件装载站在 Node 的模块机制之上——require cac
|
||||
什么让一个包成为插件?只有一条规则:**一个包的消费方式一旦是 cordis 依赖注入,它就是插件包;在此之前它是普通包。**代码怎么到达页面不属于分类体系——到达方式由包的类别推得,而不是反过来定义类别。
|
||||
|
||||
- **普通包**是模块系统自身所需的绝对基座,加上尚未转成 DI 的库:react 家族、cordis、`@deepseek-ai/dsh-client-modules`(模块系统本身——它永远不可能是插件,因为模块先于一切模块)、web 壳内核,以及——暂时——ui-slots、web-react、ui-primitives。普通包打进壳 bundle、播种进模块表、对 host 图不可见。
|
||||
- **插件包**是其余一切。每个都携带 `dsh.client` manifest(元数据清单)声明(`{ platform, inject, immediately? }`)和同一种统一形态:共享 tsdown 预设产出 `lib/client.js`,`exports["./client"]` 指向该 bundle。每个都是 host 编写的图里受治理的 entry。当前包括:connection、runtime、ui-theme、i18n、hmr(仅进 dev 图)、ui-layout、ui-sidebar、ui-conversation、ui-model-selector、ui-user-questions、ui-trajectory。
|
||||
- **插件包**是其余一切。每个都携带 `dsh.client` manifest(元数据清单)声明(`{ platform, inject, immediately? }`)和同一种统一形态:共享 tsdown 预设产出 `lib/client.js`,`exports["./client"]` 指向该 bundle。每个都是 host 编写的图里受治理的 entry,包括 connection、runtime、ui-theme、i18n、hmr、render-service 等基础设施,也包括 ui-layout、ui-conversation、ui-attachment 等功能包。
|
||||
|
||||
manifest 拥有包的装载约定:它的 `inject` 依赖边,加可选的 `immediately` 预取标记(缺省即 lazy)。负责组合的 app 只拥有名册。
|
||||
|
||||
@@ -42,13 +42,13 @@ manifest 拥有包的装载约定:它的 `inject` 依赖边,加可选的 `im
|
||||
- **插件 ↔ 插件的值 import 是构建错误。**与两侧的 `immediately` 声明无关——规则不得依赖一个人人可翻转的标记。协作走 cordis inject/服务。`import type` 豁免;类型链分毫未动。这条规则正是 `scopeOf` 是 `SessionRuntime` 方法、`transportError` 住在 `dsh-host-apiproxy` wire 层(它的 `RpcResult` 老家,内联安全)的原因。
|
||||
- **插件 → 普通包的值 import 外置为 external**,按平台清单判定。清单是壳里的一个常量(`platform.ts`:react 家族、cordis、ui-slots、web-react、ui-primitives),tsdown 预设(external 判定)与 `seed.ts`(模块表预热)都 import 它。一个常量、两个消费方——人肉同步这一漂移缺陷类死透。
|
||||
- **纯度门禁覆盖每个插件包。**它的三条分支:平台 import 外置为 external;INLINE_SAFE wire 层内联;其余任何 workspace 泄漏即构建错误。正是统一的 bundle 形态让这一覆盖不留死角——每个插件都经同一预设构建,没有包能坐在门禁之外。
|
||||
- **壳自足。**内核(boot + loading 页)对任何插件包零值 import;其状态 store 为手写。大声失败的呈现不得依赖它所报告失败的那个系统。
|
||||
- **壳自足。**除了静态接纳负责构造模块系统自身的 modules 包,内核不对任何插件包执行值 import。加载与失败页面只使用原生 DOM、本地状态和本地 CSS 回退,因此大声失败的呈现不依赖它可能报告其失败的渲染服务。
|
||||
|
||||
### 一套模块系统,一个插件治理器
|
||||
|
||||
浏览器复刻 host 侧的分工。`dsh-client-modules`(`ClientModuleSystem`)坐上 host 侧由 Node 内部 ESM loader 占据的模块系统席位;同一份 vendored `@cordisjs/plugin-loader` 在两侧都坐治理席。二者的分界线一句话说尽:**模块系统拥有模块身份与字节——代码怎么到达、怎么登记、怎么变成导出内容;Loader 拥有插件生命周期——插件何时挂载、等待什么、如何拆除。**
|
||||
|
||||
`ClientModuleSystem` 是一张 lazy CJS 表。执行 bundle 只**登记**其工厂——bundle 调用 `window.__ModuleLoader__.load({ id, factory })`,此外什么都不发生。模块体的一切副作用(包括 CSS 注入)都住在工厂闭包里,在物化时运行:物化即该 id 的首次 `require`/import,此后记忆化。工厂若 require 一个已登记未物化的同伴,就递归物化它,因此任何地方都不存在排序。被要求 import 一个 id 时,表按固定分支顺序解析:种子词条 → 记忆化的记录 → 静态登记(壳自有模块,如 app-shell)→ 已登记的工厂 → 图行外部 classic script 加载 → 大声抛错。最后这一抛是构建期纯度门禁在运行时的镜像。系统还保管逐模块的簿记——名下 `<style data-plugin>` 标签 id、观测到的 require 边——并暴露 HMR(热模块替换)需要的两个动词:`prefetch(id)`(加载脚本、只登记工厂;并发调用共享同一在途任务)与 `invalidate(id)`(丢弃工厂与记录,下次到达即重新加载)。
|
||||
`ClientModuleSystem` 是一张 lazy CJS 表。执行 bundle 只**登记**其工厂——bundle 调用 `window.__ModuleLoader__.load({ id, factory })`,此外什么都不发生。模块体的一切副作用(包括 CSS 注入)都住在工厂闭包里,在物化时运行:物化即该 id 的首次 `require`/import,此后记忆化。工厂若 require 一个已登记未物化的同伴,就递归物化它,因此任何地方都不存在排序。被要求 import 一个 id 时,表按固定分支顺序解析:种子词条 → 记忆化的记录 → 静态登记(外壳接纳的 modules 包)→ 已登记的工厂 → 图行外部 classic script 加载 → 大声抛错。最后这一抛是构建期纯度门禁在运行时的镜像。系统还保管逐模块的簿记——名下 `<style data-plugin>` 标签 id、观测到的 require 边——并暴露 HMR(热模块替换)需要的两个动词:`prefetch(id)`(加载脚本、只登记工厂;并发调用共享同一在途任务)与 `invalidate(id)`(丢弃工厂与记录,下次到达即重新加载)。
|
||||
|
||||
vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点是 `tree.import`——并拥有一切 entry 形状的事务:entry 创建、fiber 经 cordis 服务等待的激活(注入的服务未就位即保持 PENDING,服务 provide 时级联激活)、update/refresh、拆除。治理代码按 vendor 政策与 host 侧逐字节相同。浏览器化是壳 vite 配置里的编译期映射:一个 `node:module` stub 别名加若干 `process.*` define,使 `ModuleLoader.fromInternal()` 返回 undefined——这正是留给壳来填的空槽。模块系统挂载为 `ctx.modules`。
|
||||
|
||||
@@ -72,15 +72,15 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
|
||||
|
||||
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
|
||||
|
||||
**第一阶段——模块面。**壳在图之上建起模块系统,然后并行预取每个 `immediately` 行。预取即加载外部脚本,只登记工厂。单行预取失败在这里被吞下:第二阶段 import 时会重试加载并拥有那次大声失败,因此一个坏行藏不住其他行。`immediately` 是预取标记——不是屏障,不是身份。包声明它,注册表把它带进图行。基础设施插件(connection、runtime、ui-theme、i18n,外加 hmr)声明它;UI 插件则径直按需到达。
|
||||
**第一阶段——模块面。**壳在图之上建起模块系统,然后并行预取每个 `immediately` 行。预取即加载外部脚本,只登记工厂。单行预取失败在这里被吞下:第二阶段 import 时会重试加载并拥有那次大声失败,因此一个坏行藏不住其他行。`immediately` 是预取标记——不是屏障,不是身份。包声明它,注册表把它带进图行。connection、runtime、ui-theme、i18n、render-service、hmr 等基础设施插件声明它;其余 UI 插件则按需到达。
|
||||
|
||||
**第二阶段——插件面。**
|
||||
|
||||
1. 内核挂载 vendored Loader,在任何 entry 存在之前就把模块系统注入为 `internal`。顺序有讲究:`tree.import` 的裸 import 兜底分支在浏览器里绝不能跑到。
|
||||
2. 它为图中每一行创建 entry,外加 app-shell 伪行。装配 entry 是内核自己追加的壳自有代码——向模块系统静态登记,绝不进 host 图——因此与其余一切共乘同一套 entry 生命周期与状态覆盖。
|
||||
2. 它创建静态接纳的 modules 启动 entry,并为图中每一行创建 entry。渲染组装是由 `dsh-client-render-service` 提供的普通 host 图行;内核不追加组装伪 entry。
|
||||
3. 创建顺序不携带任何语义;fiber 经服务等待激活。
|
||||
4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 一次全 ACTIVE 扫描。扫描列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其缺失的服务。它存在的理由:cordis 的 inject 等待没有超时——这次扫描就是大声失败的兜底线。
|
||||
5. loading 页的启动状态是经 `internal/status` 对真实 fiber 状态的投影。settled 翻转即一次性切换到真实 UI。
|
||||
5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。检查完成后,内核调用 `ctx.appShell.mount(container)`,一次切换到真实 UI。
|
||||
|
||||
### 热重载:一个驱动插件,自行监视的 bundle
|
||||
|
||||
@@ -109,13 +109,14 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
|
||||
| react 家族 / cordis | 平台单例 | 打进壳,已播种 | 永为普通包(绝对基座) |
|
||||
| vendored `@cordisjs/plugin-loader` | entry 治理(两侧同一份代码) | 编译期浏览器化,内核挂载 | 不动(vendor 政策) |
|
||||
| `dsh-client-modules` | client 模块系统 | lazy CJS 模块表;双阶段 boot | 永为普通包(模块先于模块) |
|
||||
| `dsh-client-web` | 壳内核 + AppRoot + app-shell 装配 | 自足(手写状态 store,零插件值 import) | 持续缩小 |
|
||||
| `dsh-client-web` | 模块/Loader 内核 + 不依赖框架的启动页 | 除静态接纳 modules 启动项外保持自足 | 持续缩小 |
|
||||
| `dsh-client-render-service` | React 根 + slot 渲染器组装 | 动态插件,声明 `immediately` | 持有应用挂载生命周期 |
|
||||
| `dsh-client-ui-slots` | slot 注册表核心 | 普通包,已播种 | 升格为插件;接收 runtime 的 slots 机件 |
|
||||
| `dsh-client-web-react` | ctx↔React 胶水 | 普通包,已播种 | 升格为插件;渲染器安装移入其 apply |
|
||||
| `dsh-client-web-react` | ctx↔React 渲染适配器 | 普通包,已播种;由 render-service 安装 | 消费方改用 DI 时再升格 |
|
||||
| `dsh-client-ui-primitives` | 基础组件 | 普通包,已播种 | 升格为插件(组件经 slot/服务供给) |
|
||||
| `dsh-client-connection` | wire 层 | 插件(dsh.client + bundle),声明 `immediately` | 传输替换(Electron IPC 载体) |
|
||||
| `dsh-client-runtime` | 会话对象层 + slots 服务 + store 引擎 | 插件,声明 `immediately` | 持续缩向纯会话对象层 |
|
||||
| `dsh-client-ui-theme` | 主题 token/服务 | 插件,声明 `immediately`,外加 `./styles/*` 源码通道 | Theme Registry(另行裁定) |
|
||||
| `dsh-client-ui-theme` | 主题 token/服务 | 插件,声明 `immediately`;全局 CSS 位于其客户端 bundle | Theme Registry(另行裁定) |
|
||||
| `dsh-client-i18n` | I18nService | 插件,声明 `immediately` | 按部署组合语言包 |
|
||||
| `dsh-client-hmr` | 热重载驱动 | 插件,声明 `immediately` | 回滚;重连握手 |
|
||||
| ui-layout / ui-sidebar / ui-conversation / ui-trajectory | UI 功能 | 插件,按需到达 | conversation 域拆分;trajectory 真实现 |
|
||||
|
||||
@@ -21,7 +21,7 @@ After the typed locale standard seat landed (`locale:` on register → framework
|
||||
- **Error/failure strings stay English**: client-authored fallbacks (`command failed`, plan-toggle failures), RpcError messages, and wire `error.message (code)` pass-throughs render verbatim.
|
||||
- **Design literals stay out of the dictionaries**: tool-row variant titles (Think/Bash/…), SYSTEM/USER-style kind badges, the Plan chip wordmark, the whole StatsLine — identical in both languages.
|
||||
- **ui-trajectory is deferred wholesale** (a developer inspection surface, terminology-dense, ruled separately).
|
||||
- **Boot copy stays hardcoded** (AppRoot renders before the locale service exists).
|
||||
- **Boot copy stays hardcoded** (the framework-free boot page runs before the locale service exists).
|
||||
|
||||
**Derivation layers stay pure; localization happens at render.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank sessions and the Ungrouped bucket keep their stored titles, with the renderer substituting localized copy off the `blank` flag / absent `workspaceId`; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter, staying pure.
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`
|
||||
- **错误/失败类字符串一律英文**:client 自产的兜底串(`command failed`、plan 切换失败)、RpcError 消息、wire 透出的 `error.message (code)` 原样呈现。
|
||||
- **设计字面量不进字典**:工具行 variant 标题(Think/Bash/…)、SYSTEM/USER 类 kind 徽标、Plan chip 字标、整个 StatsLine——中英界面显示一致。
|
||||
- **ui-trajectory 整包缓做**(开发者检查面,术语密集,单独裁决)。
|
||||
- **boot 文案保持硬编码**(AppRoot 渲染早于 locale 服务可用)。
|
||||
- **boot 文案保持硬编码**(不依赖框架的启动页运行早于 locale 服务可用)。
|
||||
|
||||
**派生层保持纯函数,本地化只在渲染层**:ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}` 由渲染组合字典模板;blank 会话/未分组桶的存储标题不变,渲染按 `blank` 标志/`workspaceId` 缺席替换本地化文案;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数吃 `t` 参数保持纯。
|
||||
|
||||
|
||||
+6
@@ -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 .agents/notes/implemented/architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.md
|
||||
2026-08-17-dynamic-client-render-and-attachment-ownership.md: a3586927e0b776a8386dfca165aa30a5a300e2d3
|
||||
2026-08-17-dynamic-client-render-and-attachment-ownership.zh.md: bd9c660f62bc8eab86ecc1026914b6b9ad9c1541
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Agent Note: Dynamic client render and attachment ownership
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-17-dynamic-client-render-and-attachment-ownership.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The host-authored client graph governs browser plugins, but three presentation paths sat outside that lifecycle. The web kernel created the React root and a shell-owned assembly pseudo-entry, `ui-conversation` imported attachment components as package values, and the shell imported ui-theme's global styles. Disabling, failing, or reloading a plugin therefore did not govern all of the rendering and CSS that belonged to it.
|
||||
|
||||
The loading and failure page has the opposite requirement: it must remain usable when any dynamic plugin, including the renderer, fails to activate. It cannot depend on the React tree whose failure it reports.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-client-web` is a framework-free boot kernel. It draws its loading and failure page with DOM operations and local CSS fallbacks, constructs the client module system and Cordis Loader, creates the statically adopted modules bootstrap entry plus every host-graph entry, and waits until every fiber is ACTIVE. It then resolves `ctx.appShell` and hands the existing container to `mount()`.
|
||||
|
||||
`@deepseek-ai/dsh-client-render-service` is an `immediately` dynamic client plugin. After `slots`, `sessions`, and `layout` activate, it installs the slot renderer, provides `ctx.appShell`, creates the React root on `mount()`, projects the selected session title, and performs the sole context-level `renderSlot('root')` call. Its service, renderer installation, and React root all dispose with their owners.
|
||||
|
||||
`ui-conversation` declares `conversation.input.attachments` and `conversation.message.images` and supplies attachment data, callbacks, authorized image loading, and its locale seat. `ui-attachment` waits on those declarations through `ctx.slots.inject()` and registers the draft rail/drop target and historical image gallery/lightbox. The React implementations remain internal package values; cross-plugin composition uses slots. This package integration supersedes the direct-import ruling in the [attachment display note](../feature/2026-08-11-web-attachment-display-alignment.md) without changing that note's visual and interaction decisions.
|
||||
|
||||
ui-theme imports its five global stylesheets from its client entry. The shared client-bundle preset compiles ordinary CSS as well as CSS Modules and injects plugin-owned style tags at bundle materialization, so unloading or reloading ui-theme removes or replaces its global CSS with the same lifecycle as its service. The web kernel retains only mount defaults and the self-contained boot-page palette.
|
||||
|
||||
React, React DOM, Cordis, ui-slots, ui-primitives, and web-react remain static platform modules with one browser identity. Dynamic ownership determines which graph entry creates rendering effects; it does not duplicate these platform runtimes.
|
||||
|
||||
## Verification
|
||||
|
||||
Component tests pin the boot page, document title, application tree, attachment entries, and disposal. The assembled built-bundle boot exercises the real module table and dynamic entries, while the client-bundle CSS tests prove global styles compile into watched plugin-owned injectors. The browser replay lane covers the complete handoff from the framework-free page to the rendered application.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the shell-owned app assembly pseudo-entry.** Rejected because it remains invisible to the host graph and makes render ownership a special Loader path even though the assembly has ordinary service dependencies and lifecycle effects.
|
||||
|
||||
**Keep exported attachment atoms and import them from ui-conversation.** Rejected because a direct component import bypasses independent plugin composition and reload ownership. Owner data still travels directly through typed slot props; only presentation selection is dynamic.
|
||||
|
||||
**Keep ui-theme styles in the shell's base stylesheet.** Rejected because theme CSS would remain active when the theme plugin is absent or failed and would not participate in plugin reload cleanup.
|
||||
|
||||
**Render the failure page with React.** Rejected because a render-service or React-tree failure must not remove the only diagnostic available in the browser.
|
||||
|
||||
## Consequences
|
||||
|
||||
The host graph contains every dynamic rendering owner, and HMR replaces attachment presentation, render assembly, and theme CSS through plugin lifecycle. A render-service failure leaves a readable DOM failure page instead of a blank React mount. Omitting ui-attachment deliberately leaves its optional slots empty; the shipped web composition includes it, and a configured entry that fails activation prevents the full-application handoff.
|
||||
|
||||
The application still waits for the complete client roster before its first React frame. The shell still statically bundles the platform module identities, and the boot page maintains a small private light/dark palette because ui-theme CSS is unavailable until that plugin materializes.
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Agent Note: 客户端渲染与附件呈现的动态归属
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-17-dynamic-client-render-and-attachment-ownership.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
宿主编写的客户端图管理浏览器插件,但三条呈现路径位于其生命周期之外。Web 内核创建 React 根和由外壳持有的组装伪 entry,`ui-conversation` 以包值形式导入附件组件,外壳还导入 ui-theme 的全局样式。因此,禁用、失败或重载某个插件时,并不能同时管理属于该插件的全部渲染与 CSS。
|
||||
|
||||
加载与失败页面的要求正好相反:包括渲染器在内的任何动态插件激活失败时,它都必须保持可用。它不能依赖自己正在报告其失败的 React 树。
|
||||
|
||||
## 决定
|
||||
|
||||
`@deepseek-ai/dsh-client-web` 是不依赖框架的启动内核。它通过 DOM 操作与本地 CSS 回退绘制加载和失败页面,构造客户端模块系统与 Cordis Loader,创建静态接纳的 modules 启动 entry 和宿主图中的每个 entry,并等待所有 fiber 进入 ACTIVE。随后它解析 `ctx.appShell`,把现有容器交给 `mount()`。
|
||||
|
||||
`@deepseek-ai/dsh-client-render-service` 是带 `immediately` 标记的动态客户端插件。`slots`、`sessions` 与 `layout` 激活后,它安装 slot 渲染器、提供 `ctx.appShell`、在 `mount()` 时创建 React 根、投影当前会话标题,并执行唯一一次上下文级 `renderSlot('root')` 调用。其服务、渲染器安装和 React 根都随各自持有方 dispose。
|
||||
|
||||
`ui-conversation` 声明 `conversation.input.attachments` 与 `conversation.message.images`,并提供附件数据、回调、经会话授权的图片加载及其 locale seat。`ui-attachment` 通过 `ctx.slots.inject()` 等待这些声明,再注册草稿附件栏/拖放目标和历史图片画廊/灯箱。React 实现仍是包内值;跨插件组合通过 slot 完成。这项包集成决策取代[附件展示 Note](../feature/2026-08-11-web-attachment-display-alignment.md)中的直接导入规则,但不改变该 Note 的视觉与交互决策。
|
||||
|
||||
ui-theme 从客户端 entry 导入自己的五份全局样式表。共享客户端 bundle 预设会编译普通 CSS 与 CSS Modules,并在 bundle 物化时注入插件持有的 style 标签,因此卸载或重载 ui-theme 时,其全局 CSS 会随服务的同一生命周期删除或替换。Web 内核只保留挂载默认值与自给自足的启动页配色。
|
||||
|
||||
React、React DOM、Cordis、ui-slots、ui-primitives 与 web-react 仍是保持单一浏览器身份的静态平台模块。动态归属决定哪个图 entry 创建渲染副作用,并不会复制这些平台运行时。
|
||||
|
||||
## 验证
|
||||
|
||||
组件测试固定启动页、文档标题、应用树、附件 entry 与 dispose 行为。组装后的构建 bundle 启动测试会运行真实模块表与动态 entry,客户端 bundle CSS 测试则证明全局样式会编译成受监视、由插件持有的注入器。浏览器回放测试覆盖从不依赖框架的页面到渲染应用的完整交接。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**保留外壳持有的应用组装伪 entry。** 否决:它仍不在宿主图中,而且会把渲染归属变成特殊 Loader 路径,尽管该组装只有普通服务依赖与生命周期副作用。
|
||||
|
||||
**保留导出的附件原子组件并由 ui-conversation 导入。** 否决:直接导入组件会绕过独立插件组合与重载归属。持有方数据仍通过带类型的 slot props 直接传递;只有呈现选择是动态的。
|
||||
|
||||
**把 ui-theme 样式留在外壳的基础样式表中。** 否决:主题插件缺失或失败时,主题 CSS 仍会生效,而且不会参与插件重载清理。
|
||||
|
||||
**用 React 渲染失败页面。** 否决:渲染服务或 React 树失败时,不能连同浏览器中唯一的诊断一起移除。
|
||||
|
||||
## 结果
|
||||
|
||||
宿主图包含每个动态渲染持有方,HMR 通过插件生命周期替换附件呈现、渲染组装与主题 CSS。渲染服务失败时会留下可读的 DOM 失败页面,而不是空白 React 挂载点。有意省略 ui-attachment 会让其可选 slot 保持为空;随产品交付的 Web 组合包含该插件,而配置中存在但激活失败的 entry 会阻止完整应用交接。
|
||||
|
||||
应用首个 React 帧仍会等待完整客户端名册。外壳仍静态打包平台模块身份;由于 ui-theme CSS 要等到该插件物化后才可用,启动页还要维护一小套私有的明暗配色。
|
||||
+1
-1
@@ -12,7 +12,7 @@ The visible symptom that surfaced the gap was elsewhere. The workspace browser's
|
||||
|
||||
## Decision
|
||||
|
||||
`packages/client/ui-theme/src/styles/scrollbar.css` is the sole consumer of the four tokens, and the fifth ui-theme sheet in the shell's import chain (`packages/client/web/src/base.css`). It follows `design-platform.css` there because it reads that sheet's tokens.
|
||||
`packages/client/ui-theme/src/styles/scrollbar.css` is the sole consumer of the four tokens and the third global sheet imported by ui-theme's dynamic client entry. It follows `design-platform.css` there because it reads that sheet's tokens; both compile into ui-theme's plugin-owned client bundle.
|
||||
|
||||
The rules sit on `body`, not `html`. `design-platform.css` declares the `--dsw-alias-*` tokens on `body`, with the dark overrides on `body[data-ds-dark-theme]`, and custom properties inherit only downward; an `html` rule resolves them to the guaranteed-invalid value, at which point `scrollbar-color` computes to `auto` and no theming happens at all.
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
`packages/client/ui-theme/src/styles/scrollbar.css` 是这四个 token 的唯一消费方,也是壳的导入链(`packages/client/web/src/base.css`)中第五张 ui-theme 样式表。它排在 `design-platform.css` 之后,因为它读取那张样式表的 token。
|
||||
`packages/client/ui-theme/src/styles/scrollbar.css` 是这四个 token 的唯一消费方,也是 ui-theme 动态客户端 entry 导入的第三张全局样式表。它排在 `design-platform.css` 之后,因为它读取那张样式表的 token;两者都会编译进 ui-theme 持有的客户端 bundle。
|
||||
|
||||
规则挂在 `body` 上,而非 `html`。`design-platform.css` 在 `body` 上声明 `--dsw-alias-*` token,暗色覆盖挂在 `body[data-ds-dark-theme]` 上,而自定义属性只向下继承;挂在 `html` 上的规则会把它们解析为 guaranteed-invalid 值,此时 `scrollbar-color` 计算为 `auto`,主题完全不起作用。
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ English | [中文](2026-08-10-pre-plugin-theme-bootstrap.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The web shell renders `Loading plugins…` before the browser-side plugin tree activates. The theme tokens are already loaded with the shell styles, but `color-scheme` and `body[data-ds-dark-theme]` are not written until ui-theme's ThemeRuntime and ui-layout's ThemePresenter activate; with a persisted dark preference, the loading page therefore renders first with the light palette and then switches to dark.
|
||||
The web shell renders `Loading plugins…` before the browser-side plugin tree activates. ui-theme's token styles arrive with its dynamic client bundle, so the framework-free loading page uses a private light/dark fallback palette. Without an earlier write to `color-scheme` and `body[data-ds-dark-theme]`, a persisted dark preference would still render that page first with its light fallback and then switch to dark when ui-theme's ThemeRuntime and ui-layout's ThemePresenter activate.
|
||||
|
||||
`dshClient.immediately` only includes the bundle in first-stage prefetching; it does not cause the plugin to execute before HTML parsing or the shell's initial render. Changing only the client plugin's loading tier cannot close this window.
|
||||
|
||||
## Decision
|
||||
|
||||
ui-theme's host half transforms each index HTML document through `ctx.webServer.tapIndex()`, inserting a synchronous inline script immediately after the opening `<body>` tag. The transform registers under an optional `httpServer` injection, so compositions without that service still activate ui-theme and install no transform. When the HTML parser executes the script, the body exists, but the shell's module script and React root have not yet run.
|
||||
ui-theme's host half transforms each index HTML document through `ctx.webServer.tapIndex()`, inserting a synchronous inline script immediately after the opening `<body>` tag. The transform registers under an optional `httpServer` injection, so compositions without that service still activate ui-theme and install no transform. When the HTML parser executes the script, the body exists, but the shell's module script and framework-free boot page have not yet run.
|
||||
|
||||
The host half registers the [`ui-theme.preference` settings section](2026-08-06-host-backed-web-preferences.md) when a settings provider exists. For each index response, it embeds that schema-validated built-in preference in the inline script; without a settings provider or active registration, it embeds the `system` default. The browser resolves `system` through `prefers-color-scheme`, falling back to light when `matchMedia` is unavailable. It writes only the two pieces of DOM state that ThemePresenter later owns: `document.documentElement.style.colorScheme` and `body[data-ds-dark-theme]`.
|
||||
|
||||
@@ -34,4 +34,4 @@ ui-theme's unit tests cover activation without either optional Host service, the
|
||||
|
||||
## Consequences
|
||||
|
||||
The loading page's first frame matches the durable built-in preference and defaults to the OS preference when no settings provider is composed. The index transform reads Host settings for every response, while the inline script contains only the selected built-in value and `system` resolution. Changes to the built-in preference semantics or ThemePresenter DOM fields must update both the script and ThemeRuntime. A custom theme still applies fully only after the browser plugins activate; during the loading interval, the page uses the light or dark base palette to which that theme resolves.
|
||||
The loading page's first frame matches the durable built-in preference and defaults to the OS preference when no settings provider is composed. The index transform reads Host settings for every response, while the inline script contains only the selected built-in value and `system` resolution. Changes to the built-in preference semantics or ThemePresenter DOM fields must update both the script and ThemeRuntime. A custom theme still applies fully only after the browser plugins activate; during the loading interval, the page uses its private light or dark fallback palette.
|
||||
|
||||
@@ -6,13 +6,13 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
Web 壳在浏览器侧插件树激活前呈现 `Loading plugins…`。主题 token 已随壳样式加载,但 `color-scheme` 和 `body[data-ds-dark-theme]` 要等 ui-theme 的 ThemeRuntime 与 ui-layout 的 ThemePresenter 激活后才写入;持久化偏好为深色时,加载页因此先按浅色调色板绘制,再切为深色。
|
||||
Web 壳在浏览器侧插件树激活前呈现 `Loading plugins…`。ui-theme 的 token 样式随动态客户端 bundle 到达,因此不依赖框架的加载页使用私有的明暗回退配色。如果不提前写入 `color-scheme` 与 `body[data-ds-dark-theme]`,持久化偏好为深色时,该页面仍会先按浅色回退绘制,再在 ui-theme 的 ThemeRuntime 与 ui-layout 的 ThemePresenter 激活后切为深色。
|
||||
|
||||
`dshClient.immediately` 只把 bundle 纳入第一阶段预取,不会让插件在 HTML 解析或壳首次渲染前执行。仅调整客户端插件的加载档位无法关闭这段时间窗口。
|
||||
|
||||
## 决策
|
||||
|
||||
ui-theme 的主机侧通过 `ctx.webServer.tapIndex()` 转换每份 index HTML,在 `<body>` 起始标签后紧接一段同步内联脚本。该转换通过可选的 `httpServer` 注入注册,因此不含该服务的组合仍会激活 ui-theme,但不会安装转换。HTML 解析器执行该脚本时,body 已存在,而壳的模块脚本与 React 根节点尚未运行。
|
||||
ui-theme 的主机侧通过 `ctx.webServer.tapIndex()` 转换每份 index HTML,在 `<body>` 起始标签后紧接一段同步内联脚本。该转换通过可选的 `httpServer` 注入注册,因此不含该服务的组合仍会激活 ui-theme,但不会安装转换。HTML 解析器执行该脚本时,body 已存在,而壳的模块脚本与不依赖框架的启动页尚未运行。
|
||||
|
||||
settings provider 存在时,主机侧会注册 [`ui-theme.preference` settings 分节](2026-08-06-host-backed-web-preferences.md)。它为每份 index 响应把经过 schema 校验的内建偏好嵌入内联脚本;不存在 settings provider 或有效注册时则嵌入默认值 `system`。浏览器通过 `prefers-color-scheme` 解析 `system`,不支持 `matchMedia` 时回退为浅色。脚本只写 ThemePresenter 后续拥有的两项 DOM 状态:`document.documentElement.style.colorScheme` 与 `body[data-ds-dark-theme]`。
|
||||
|
||||
@@ -34,4 +34,4 @@ ui-theme 的单元测试覆盖不含任一可选 Host 服务时的激活、脚
|
||||
|
||||
## 后果
|
||||
|
||||
加载页首帧与持久化内建偏好一致;未组合 settings provider 时则默认采用系统偏好。index 转换会为每份响应读取 Host settings,而内联脚本只包含选定的内建值与 `system` 解析逻辑。内建偏好语义或 ThemePresenter DOM 字段变化时,必须同时更新脚本与 ThemeRuntime。自定义主题仍会在浏览器插件激活后才完整应用;加载期间,页面使用该主题解析后的浅色或深色基础调色板。
|
||||
加载页首帧与持久化内建偏好一致;未组合 settings provider 时则默认采用系统偏好。index 转换会为每份响应读取 Host settings,而内联脚本只包含选定的内建值与 `system` 解析逻辑。内建偏好语义或 ThemePresenter DOM 字段变化时,必须同时更新脚本与 ThemeRuntime。自定义主题仍会在浏览器插件激活后才完整应用;加载期间,页面使用自己的浅色或深色回退配色。
|
||||
|
||||
@@ -14,7 +14,7 @@ All of this UI also lived inside `dsh-client-ui-conversation` — the rail inlin
|
||||
|
||||
## Decision
|
||||
|
||||
Attachment display lives in a new zero-cordis atoms package, `@deepseek-ai/dsh-client-ui-attachment` (`packages/client/ui-attachment`), patterned on `dsh-client-ui-primitives`: `AttachmentRail` (64px/16px-radius thumbnails, single-click `onOpen`, inside-the-card remove control revealed on hover or focus and permanent under `pointer: coarse`, hidden scrollbar with circular edge arrows recomputed from scroll geometry, vertical-wheel horizontal pan clamped to 60px/tick, end-reveal on growth), `MessageImage`/`ImageGallery` (single-click preview), and `ImageLightbox`. Strings arrive as label props; `ui-conversation` bridges its `conversation` dictionary through `src/client/image-labels.ts` and keeps the machine wiring (draft ids, preview state, intake callbacks). The cross-package import is sanctioned exactly because the package is an atoms library, not a client plugin: plugin-to-plugin component imports stay forbidden, and the composer's rail is composer-owned rendering, not a slot.
|
||||
Attachment display lives in `@deepseek-ai/dsh-client-ui-attachment` (`packages/client/ui-attachment`): `AttachmentRail` (64px/16px-radius thumbnails, single-click `onOpen`, inside-the-card remove control revealed on hover or focus and permanent under `pointer: coarse`, hidden scrollbar with circular edge arrows recomputed from scroll geometry, vertical-wheel horizontal pan clamped to 60px/tick, end-reveal on growth), `MessageImage`/`ImageGallery` (single-click preview), and `ImageLightbox`. These remain internal pure-props components. `ui-conversation` declares the composer-attachment and message-image slots and supplies draft ids, image loading, intake callbacks, and its locale seat; the dynamic ui-attachment client entry waits on those declarations and registers the presentation. The [dynamic render and attachment ownership note](../architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.md) owns this package integration; the visual behavior recorded here is unchanged.
|
||||
|
||||
Both overlays body-portal: the lightbox opened from a chat message sits under transformed ancestors that would trap `position: fixed` in their own box (the backdrop covered only the chat column), so `ImageLightbox` and `Toast` render through `createPortal(document.body)` and cover the viewport from every opener. The transient banner is a `ui-primitives` `Toast` atom (120px from the viewport top, horizontally centered over its optional anchor — the composer card, so it sits over the chat column — `role="alert"`, `pointer-events: none`, three-second hold then one-second fade, `onDone` unmount, keyed per show so identical repeated messages re-announce). `InputBar` routes both intake rejections (`addImages`'s returned reason) and `promptError` through it, replacing the inline strips, and `ModelSelect` routes rejected model selections through the same atom while its in-menu strip with Retry stays the catalog-load surface; the machine-notice strip is untouched. DeepSeek Chat's source (a local reference copy) provided the target behaviors: its `ImageThumbnailInInput` (64px cards, opacity-transition delete), `ScrollArrows` (sentinel-driven paging), and `useToast` usage.
|
||||
|
||||
@@ -22,7 +22,7 @@ Both overlays body-portal: the lightbox opened from a chat message sits under tr
|
||||
|
||||
**Keep the components inside `ui-conversation` and only restyle.** Rejected by the user: the attachment surface is expected to grow (file cards, upload progress), and the repo's plugin discipline forbids other plugins importing `ui-conversation` internals, so growth inside the plugin builds an unreusable pile. The atoms package gives the same components a sanctioned import path.
|
||||
|
||||
**A `ui-attachment` client plugin registering slots.** Rejected: the rail renders inside the composer the machine owns and the gallery inside chat nodes; neither is a composition hole another plugin should fill, and a plugin would force slot indirection for what are pure presentational components.
|
||||
**Export attachment atoms and import them directly from `ui-conversation`.** Rejected by the package integration decision: direct component imports bypass dynamic plugin lifecycle and cross-plugin slot composition. The conversation package still owns the data and render sites, while ui-attachment owns their optional presentation entries.
|
||||
|
||||
**Toast inside `ui-conversation`.** Rejected: nothing about a transient banner is conversation-specific, and `ui-primitives` is the established home for zero-cordis atoms other surfaces may reuse.
|
||||
|
||||
@@ -30,4 +30,4 @@ Both overlays body-portal: the lightbox opened from a chat message sits under tr
|
||||
|
||||
## Consequences
|
||||
|
||||
The composer and history image surfaces now match DeepSeek Chat's interaction model, and the label-prop seam means the atoms render under any locale without reaching for one. The cost is a real package boundary: `ui-attachment` carries the standard scaffolding (invariant companion, bilingual README, tsconfig face, per-file 100% coverage) and item strings must be resolved by every future consumer rather than inherited. Error banners are now transient — a user who looks away for four seconds misses the message, the trade DeepSeek Chat itself makes. Non-image attachments remain unsupported; the rail's card model is ready for them but the composer's intake is image-only (tracked in the package README's limitations).
|
||||
The composer and history image surfaces match DeepSeek Chat's interaction model, and the pure-props components render under the conversation slot's locale seat without reaching into application state. The cost is a real dynamic package boundary: `ui-attachment` carries the standard plugin scaffolding (client bundle, invariant companion, bilingual README, tsconfig face, per-file 100% coverage), and omitting it leaves the two optional attachment slots empty. Error banners are transient — a user who looks away for four seconds misses the message, the trade DeepSeek Chat itself makes. Non-image attachments remain unsupported; the rail's card model is ready for them but the composer's intake is image-only (tracked in the package README's limitations).
|
||||
|
||||
@@ -14,7 +14,7 @@ Web 输入框的图片界面缺乏基本可用性(用户反馈,issue #2248
|
||||
|
||||
## 决定
|
||||
|
||||
附件展示落位到新的零 cordis 原子组件包 `@deepseek-ai/dsh-client-ui-attachment`(`packages/client/ui-attachment`),模式照 `dsh-client-ui-primitives`:`AttachmentRail`(64px、16px 圆角缩略图,单击 `onOpen`,卡片内部的删除按钮悬停或聚焦显示、`pointer: coarse` 下常显,隐藏滚动条配两端圆形箭头并依滚动几何重算,纵向滚轮转横向平移且单次钳制 60px,新增条目滚到栏尾),`MessageImage`/`ImageGallery`(单击预览),以及 `ImageLightbox`。文案经 label props 传入;`ui-conversation` 通过 `src/client/image-labels.ts` 桥接 `conversation` 词典,并保留状态机接线(草稿 id、预览状态、接收回调)。跨包 import 之所以是被允许的路径,正因为它是原子组件库而非 client 插件:插件之间仍禁止互相 import 组件,且附件栏是输入框自有的渲染,不是插槽。
|
||||
附件展示位于 `@deepseek-ai/dsh-client-ui-attachment`(`packages/client/ui-attachment`):`AttachmentRail`(64px、16px 圆角缩略图,单击 `onOpen`,卡片内部的删除按钮悬停或聚焦显示、`pointer: coarse` 下常显,隐藏滚动条配两端圆形箭头并依滚动几何重算,纵向滚轮转横向平移且单次钳制 60px,新增条目滚到栏尾),`MessageImage`/`ImageGallery`(单击预览),以及 `ImageLightbox`。这些组件仍是包内的纯 props 组件。`ui-conversation` 声明输入框附件与消息图片 slot,并提供草稿 id、图片加载、接收回调及其 locale seat;动态 ui-attachment 客户端 entry 等待这些声明并注册呈现。[动态渲染与附件归属 Note](../architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.md)负责这项包集成决策;本 Note 记录的视觉行为保持不变。
|
||||
|
||||
两个浮层都 portal 到 body:从聊天消息打开的灯箱位于带 transform 的祖先之下,`position: fixed` 会被困在祖先的盒子里(遮罩只盖住聊天列),因此 `ImageLightbox` 与 `Toast` 经 `createPortal(document.body)` 渲染,从任何打开位置都覆盖整个视口。短时横幅是 `ui-primitives` 的 `Toast` 原子(距视口顶部 120px,水平中心跟随可选锚点——composer 卡片,因此横幅在聊天列上居中——`role="alert"`、`pointer-events: none`,停留三秒再一秒淡出,`onDone` 卸载,按展示序号作 key 使相同文案重新播报)。`InputBar` 把接收拒绝(`addImages` 返回的原因)和 `promptError` 都改走 toast,替换内联红条,`ModelSelect` 的模型选择被拒也走同一原子,其菜单内带 Retry 的错误条仍是目录加载的呈现面;状态机 notice 条不受影响。DeepSeek Chat 源码(本地参考副本)提供了目标行为:其 `ImageThumbnailInInput`(64px 卡片、透明度过渡的删除钮)、`ScrollArrows`(哨兵驱动的翻页)与 `useToast` 用法。
|
||||
|
||||
@@ -22,7 +22,7 @@ Web 输入框的图片界面缺乏基本可用性(用户反馈,issue #2248
|
||||
|
||||
**组件留在 `ui-conversation` 里只改样式。** 被用户否决:附件面预期还会长(文件卡片、上传进度),而仓库的插件纪律禁止其他插件 import `ui-conversation` 内部实现,在插件里生长只会堆出无法复用的一坨。原子组件包给了同样的组件一条被允许的 import 路径。
|
||||
|
||||
**做成注册插槽的 `ui-attachment` client 插件。** 否决:附件栏渲染在状态机持有的输入框里,画廊渲染在聊天节点里,二者都不是该由其他插件填充的组合孔位,插件形态会为纯展示组件强加插槽间接层。
|
||||
**导出附件原子组件并由 `ui-conversation` 直接导入。** 被包集成决策否决:直接导入组件会绕过动态插件生命周期与跨插件 slot 组合。conversation 包仍持有数据与渲染位置,ui-attachment 则持有其中可选的呈现 entry。
|
||||
|
||||
**Toast 放在 `ui-conversation`。** 否决:短时横幅没有任何会话特有的东西,`ui-primitives` 是零 cordis 原子组件的既定归属,其他界面也可能复用。
|
||||
|
||||
@@ -30,4 +30,4 @@ Web 输入框的图片界面缺乏基本可用性(用户反馈,issue #2248
|
||||
|
||||
## 结果
|
||||
|
||||
输入框与历史图片界面的交互模型现已与 DeepSeek Chat 一致,label props 接缝让原子组件在任何语言环境下渲染而无需触达 locale。代价是一个真实的包边界:`ui-attachment` 背上标准脚手架(invariant 伴生、双语 README、tsconfig face、逐文件 100% 覆盖率),且每个未来消费者都要自行解析条目文案而非继承。错误横幅变为短时——用户移开视线四秒就会错过消息,这正是 DeepSeek Chat 自己做的取舍。非图片附件仍不支持;附件栏的卡片模型已就绪,但输入框的接收仍只认图片(记录于包 README 的限制一节)。
|
||||
输入框与历史图片界面的交互模型与 DeepSeek Chat 一致,纯 props 组件通过 conversation slot 的 locale seat 渲染,无需触达应用状态。代价是一个真实的动态包边界:`ui-attachment` 带有标准插件脚手架(客户端 bundle、invariant 伴生、双语 README、tsconfig face、逐文件 100% 覆盖率),省略该插件会让两个可选附件 slot 保持为空。错误横幅是短时的——用户移开视线四秒就会错过消息,这正是 DeepSeek Chat 自己做的取舍。非图片附件仍不支持;附件栏的卡片模型已就绪,但输入框的接收仍只认图片(记录于包 README 的限制一节)。
|
||||
|
||||
@@ -16,7 +16,7 @@ The client rendered every code surface — markdown fences in assistant prose, t
|
||||
|
||||
- **Dependency**: `shiki/core` + `@shikijs/langs`, composed via `createHighlighterCoreSync` with `createJavaScriptRegexEngine({ forgiving: true })` — no oniguruma WASM, no async init, bundle-friendly. Grammar allowlist: `typescript` (embeds JS), `shellscript`, `json` — the languages the harness actually renders; everything else falls back to a geometry-identical plain block, never an error. Prior art: the VitePress site already renders all documentation code through shiki, and TextMate grammars materially beat regex highlighters on TypeScript — the payload that matters here.
|
||||
- **Singleton**: `ui-primitives/src/markdown/highlight.ts` creates one `HighlighterCore` per document and exposes `highlightToHtml(code, lang)` (undefined = render plain). Engine + grammar construction is a ~120-175ms long task, so the module pre-warms the singleton in a deferred task at plugin boot (the lazy path stays as the correctness fallback), keeping the cost off the render path where a stream's finalize swap would jank. The alias table is a `Map`, not an object: fence info strings are assistant-authored, so a label like `constructor` must miss instead of resolving an inherited property and crashing shiki. The shared `CodeBlock` component owns both arms; its shiki arm injects the generated span tree via `dangerouslySetInnerHTML` — sanctioned because shiki emits a static span tree computed from the code text (no user HTML passes through, no scripts/handlers), shiki's own documented consumption path.
|
||||
- **Theming**: shiki's `createCssVariablesTheme` routes every token color through `--shiki-*` custom properties; the VALUES live in a new `ui-theme/styles/shiki.css` token sheet (light on `:root`, dark on `body[data-ds-dark-theme]` — the same cascade as every other sheet), imported by the shell's `base.css` chain. Component CSS stays tokens-only; no literal color ever enters JS or component sheets. Background/foreground alias the existing markdown code-block tokens so highlighted and plain blocks agree.
|
||||
- **Theming**: shiki's `createCssVariablesTheme` routes every token color through `--shiki-*` custom properties; the VALUES live in `ui-theme/styles/shiki.css` (light on `:root`, dark on `body[data-ds-dark-theme]` — the same cascade as every other sheet), imported by ui-theme's dynamic client entry and compiled into its plugin-owned global CSS. Component CSS stays tokens-only; no literal color ever enters JS or component sheets. Background/foreground alias the existing markdown code-block tokens so highlighted and plain blocks agree.
|
||||
- **Surfaces**: markdown fences (`MarkdownText`'s `pre` component routes single-string fences through `CodeBlock`), the `run_code` expanded program body (ToolRow's code variant, `lang="typescript"`), and the details panel's Input args (`lang="json"`). Tool output is never syntax-highlighted — it is arbitrary text, and guessing a grammar would mis-highlight more than it helps; a bash card's output carries only the color its own ANSI sequences declare, through [the terminal card](../feature/2026-07-28-web-terminal-card.md).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -16,7 +16,7 @@ client 过去把每一处代码表面——assistant 正文里的 markdown 围
|
||||
|
||||
- **依赖**:`shiki/core` + `@shikijs/langs`,经 `createHighlighterCoreSync` 搭配 `createJavaScriptRegexEngine({ forgiving: true })` 组装——不带 oniguruma WASM、没有异步初始化、对 bundle 友好。语法(grammar)白名单:`typescript`(内嵌 JS)、`shellscript`、`json`——即 harness 实际会渲染的那几种语言;其余一律回退到几何完全一致的纯文本块,绝不报错。先例:VitePress 站点已经通过 shiki 渲染全部文档代码;而在 TypeScript(正是此处要紧的载荷)上,TextMate 语法实质性优于正则高亮器。
|
||||
- **单例**:`ui-primitives/src/markdown/highlight.ts` 为每个 document 创建一个 `HighlighterCore`,并公开 `highlightToHtml(code, lang)`(undefined 即渲染为纯文本)。引擎加语法的构建是一次约 120-175ms 的长任务,因此模块在插件启动时用延迟任务预热单例(惰性路径保留为正确性兜底),把这笔开销挪出渲染路径——否则流式 finalize 交换的那一刻会卡顿。别名表用 `Map` 而非对象:fence 信息字符串由 assistant 撰写,诸如 `constructor` 这样的标签必须落空,而不是解析到继承属性并让 shiki 崩溃。共享的 `CodeBlock` 组件同时拥有两条分支;其 shiki 分支经 `dangerouslySetInnerHTML` 注入生成的 span 树——此用法获准,因为 shiki 输出的是从代码文本计算出的静态 span 树(不流经任何用户 HTML,没有脚本或事件处理器),这正是 shiki 自身文档载明的消费路径。
|
||||
- **主题化**:shiki 的 `createCssVariablesTheme` 让每一种 token 颜色都经由 `--shiki-*` 自定义属性路由;取值本身住在新增的 `ui-theme/styles/shiki.css` token 表里(亮色在 `:root`、暗色在 `body[data-ds-dark-theme]`——层叠方式与其余每张样式表相同),由壳的 `base.css` 导入链引入。组件 CSS 保持只用 token;任何颜色字面量都不进入 JS 或组件样式表。背景/前景以别名指向既有的 markdown 代码块 token,使高亮块与纯文本块彼此一致。
|
||||
- **主题化**:shiki 的 `createCssVariablesTheme` 让每一种 token 颜色都经由 `--shiki-*` 自定义属性路由;取值本身住在 `ui-theme/styles/shiki.css` token 表里(亮色在 `:root`、暗色在 `body[data-ds-dark-theme]`——层叠方式与其余每张样式表相同),由 ui-theme 的动态客户端 entry 导入并编译进该插件持有的全局 CSS。组件 CSS 保持只用 token;任何颜色字面量都不进入 JS 或组件样式表。背景/前景以别名指向既有的 markdown 代码块 token,使高亮块与纯文本块彼此一致。
|
||||
- **表面**:markdown 围栏代码块(`MarkdownText` 的 `pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文(ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。工具输出从不做语法高亮——它是任意文本,硬猜一种语法,带来的误高亮会多于帮助;bash 卡片的输出只承载其自身 ANSI 序列声明的颜色,经由[终端卡片](../feature/2026-07-28-web-terminal-card.md)渲染。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Web application entry: thin bootstrap over the shell library. Everything —
|
||||
* loader holding, module-table seeding, AppRoot gate, plugin assembly — lives
|
||||
* module-table seeding, the boot page, and the render-service handoff — lives
|
||||
* in @deepseek-ai/dsh-client-web; this file only finds the mount point.
|
||||
*/
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
@@ -28,8 +28,10 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-api-remotes'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-api-remotes'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-render-service', bundlePath: 'packages/client/render-service/lib/client.js', url: '/plugins/render-service.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-layout'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-attachment', bundlePath: 'packages/client/ui-attachment/lib/client.js', url: '/plugins/ui-attachment.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-tool', bundlePath: 'packages/client/ui-tool/lib/client.js', url: '/plugins/ui-tool.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-workflow-run', bundlePath: 'packages/client/ui-workflow-run/lib/client.js', url: '/plugins/ui-workflow-run.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{
|
||||
@@ -64,7 +66,7 @@ class ResizeObserverStub {
|
||||
}
|
||||
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
let unmount: (() => Promise<void>) | undefined
|
||||
|
||||
/**
|
||||
* Register the per-test jsdom setup and teardown the assembled boot needs:
|
||||
@@ -89,8 +91,8 @@ export function installAssembledBootEnv(): void {
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { unmount?.() })
|
||||
afterEach(async () => {
|
||||
await act(async () => { await unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
@@ -127,7 +129,7 @@ export function mountAssembledApp(): void {
|
||||
},
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
unmount = () => entry.dispose()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -139,12 +139,10 @@ export default defineConfig({
|
||||
// Browserization of the vendored cordis Loader: its only node-only
|
||||
// import; the two process probes are mapped by `define` below.
|
||||
{ find: /^node:module$/, replacement: src('./src/node-module-stub.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-web$/, replacement: src('../../packages/client/web/src/boot.tsx') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-web$/, replacement: src('../../packages/client/web/src/boot.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-ui-attachment$/, replacement: src('../../packages/client/ui-attachment/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-schema-form$/, replacement: src('../../packages/client/schema-form/src/index.ts') },
|
||||
{ find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -699,10 +699,18 @@
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"tests/**/*.ts",
|
||||
"tests/**/*.tsx"
|
||||
]
|
||||
},
|
||||
"packages/client/render-service": {
|
||||
"entry": [
|
||||
"tests/**/*.spec.tsx"
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
"@deepseek-ai/dsh-client-ui-theme"
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"tests/**/*.tsx"
|
||||
]
|
||||
},
|
||||
"packages/client/ui-settings": {
|
||||
|
||||
@@ -180,6 +180,9 @@
|
||||
- id: ui-layout
|
||||
name: '@deepseek-ai/dsh-client-ui-layout'
|
||||
|
||||
- id: render-service
|
||||
name: '@deepseek-ai/dsh-client-render-service'
|
||||
|
||||
- id: ui-sidebar
|
||||
name: '@deepseek-ai/dsh-client-ui-sidebar'
|
||||
|
||||
@@ -198,6 +201,9 @@
|
||||
- id: ui-conversation
|
||||
name: '@deepseek-ai/dsh-client-ui-conversation'
|
||||
|
||||
- id: ui-attachment
|
||||
name: '@deepseek-ai/dsh-client-ui-attachment'
|
||||
|
||||
# Tool call tree, generic fallback, and keyed business Tool views.
|
||||
- id: ui-tool
|
||||
name: '@deepseek-ai/dsh-client-ui-tool'
|
||||
|
||||
@@ -51,8 +51,10 @@
|
||||
"@deepseek-ai/dsh-client-hmr": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-render-service": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-cordis": "workspace:^",
|
||||
|
||||
@@ -7,18 +7,18 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| [`web/`](web/README.md) | Boots the browser shell from the client entry graph. |
|
||||
| [`render-service/`](render-service/README.md) | Mounts the assembled React application after client boot settles. |
|
||||
| [`modules/`](modules/README.md) | Loads browser-side client modules. |
|
||||
| [`web-react/`](web-react/README.md) | Connects the shell runtime to React rendering. |
|
||||
| [`connection/`](connection/README.md) | Maintains browser-host RPC communication and event delivery. |
|
||||
| [`runtime/`](runtime/README.md) | Provides shared client services for sessions, workspaces, and UI composition. |
|
||||
| [`hmr/`](hmr/README.md) | Refreshes client plugins during development. |
|
||||
| [`locale/`](locale/README.md) | Provides localization preferences and message dictionaries. |
|
||||
| [`schema-form/`](schema-form/README.md) | Provides schema-backed draft handling for settings editors. |
|
||||
| [`test-runtime/`](../test-support/client-runtime/README.md) | Provides shared repository test support for client feature packages. |
|
||||
| [`ui-slots/`](ui-slots/README.md) | Defines how UI features register and compose extension slots. |
|
||||
| [`ui-theme/`](ui-theme/README.md) | Applies the selected color theme. |
|
||||
| [`ui-primitives/`](ui-primitives/README.md) | Provides shared React controls, icons, and content renderers. |
|
||||
| [`ui-attachment/`](ui-attachment/README.md) | Provides attachment display atoms: draft-image rail, message gallery, and lightbox. |
|
||||
| [`ui-attachment/`](ui-attachment/README.md) | Registers composer and message-image attachment presentation. |
|
||||
| [`ui-layout/`](ui-layout/README.md) | Arranges the main application regions. |
|
||||
| [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation. |
|
||||
| [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. |
|
||||
|
||||
@@ -7,18 +7,18 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
|
||||
| 包 | 目的 |
|
||||
|---|---|
|
||||
| [`web/`](web/README.md) | 从客户端条目图启动浏览器 shell。 |
|
||||
| [`render-service/`](render-service/README.md) | 在客户端启动稳定后挂载组装完成的 React 应用。 |
|
||||
| [`modules/`](modules/README.md) | 加载浏览器侧客户端模块。 |
|
||||
| [`web-react/`](web-react/README.md) | 连接 shell 运行时与 React 渲染。 |
|
||||
| [`connection/`](connection/README.md) | 维护浏览器与宿主之间的 RPC 通信和事件传递。 |
|
||||
| [`runtime/`](runtime/README.md) | 为会话、工作区和 UI 组合提供共享客户端服务。 |
|
||||
| [`hmr/`](hmr/README.md) | 在开发期间刷新客户端插件。 |
|
||||
| [`locale/`](locale/README.md) | 提供本地化偏好与消息词典。 |
|
||||
| [`schema-form/`](schema-form/README.md) | 为设置编辑器提供 schema 驱动的草稿处理。 |
|
||||
| [`test-runtime/`](../test-support/client-runtime/README.md) | 为客户端功能包提供共享的仓库测试支持。 |
|
||||
| [`ui-slots/`](ui-slots/README.md) | 定义 UI 功能注册和组合扩展 slot 的方式。 |
|
||||
| [`ui-theme/`](ui-theme/README.md) | 应用所选颜色主题。 |
|
||||
| [`ui-primitives/`](ui-primitives/README.md) | 提供共享 React 控件、图标和内容渲染器。 |
|
||||
| [`ui-attachment/`](ui-attachment/README.md) | 提供附件展示原子组件:草稿图片栏、消息画廊与灯箱。 |
|
||||
| [`ui-attachment/`](ui-attachment/README.md) | 注册输入框与消息图片的附件呈现。 |
|
||||
| [`ui-layout/`](ui-layout/README.md) | 排列应用的主要区域。 |
|
||||
| [`ui-sidebar/`](ui-sidebar/README.md) | 展示工作区与会话导航。 |
|
||||
| [`ui-workspace/`](ui-workspace/README.md) | 提供工作区选择与创建界面。 |
|
||||
|
||||
@@ -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 packages/client/render-service/README.md
|
||||
README.md: 22c46ad6586821f859d86512eef5aba65f5d0af6
|
||||
README.zh.md: 8815a93cdc847e855e8be7bec20bf873770063a8
|
||||
@@ -0,0 +1,19 @@
|
||||
# @deepseek-ai/dsh-client-render-service
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The browser Cordis plugin that owns React mounting. [`dsh-client-web`](../web/README.md) renders a framework-free boot page and loads the complete client plugin roster; after every entry activates, it calls `ctx.appShell.mount(container)`. This package provides that service, installs the slot renderer, creates the React root, and returns its unmount disposer.
|
||||
|
||||
The plugin activates after `slots`, `sessions`, and `layout`. Its application tree projects the selected session title and performs the sole ctx-level `renderSlot('root')` call. React, React DOM, ui-slots, ui-primitives, and web-react retain one browser identity through the web shell's static module table; this package arrives as a dynamic client bundle.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None. The render service assembles browser UI and contributes no model-visible input.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends provider requests.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The first application frame waits for every client entry** — the boot kernel hands over the mount point only after the loader roster settles. Per-region readiness remains deferred.
|
||||
@@ -0,0 +1,19 @@
|
||||
# @deepseek-ai/dsh-client-render-service
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
负责 React 挂载的浏览器 Cordis 插件。[`dsh-client-web`](../web/README.md) 渲染不依赖框架的启动页并加载完整的客户端插件名册;所有 entry 激活后,它调用 `ctx.appShell.mount(container)`。本包提供该服务、安装 slot 渲染器、创建 React 根,并返回卸载 disposer。
|
||||
|
||||
插件在 `slots`、`sessions` 和 `layout` 就绪后激活。它的应用树投影当前会话标题,并执行全程序唯一一次 ctx 级 `renderSlot('root')` 调用。React、React DOM、ui-slots、ui-primitives 和 web-react 通过 web 外壳的静态模块表保持同一浏览器身份;本包则以动态客户端 bundle 到达。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。渲染服务只组装浏览器 UI,不贡献模型可见输入。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;本包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **应用首帧会等待全部客户端 entry**——启动内核只在 loader 名册稳定后交出挂载点。按区域就绪仍属暂缓事项。
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-render-service",
|
||||
"description": "Browser render service: installs the slot renderer, provides ctx.appShell, and mounts the assembled React application",
|
||||
"version": "0.1.0-rc.6",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/render-service"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/** Props for the shell-owned browser title projection. */
|
||||
/** Props for the browser title projection. */
|
||||
export interface DocumentTitleProps {
|
||||
/** Durable title of the selected session, or undefined for the product title. */
|
||||
title?: string
|
||||
@@ -8,9 +8,9 @@ export interface DocumentTitleProps {
|
||||
|
||||
/**
|
||||
* Project the selected durable session title into the browser title and
|
||||
* restore the shell's original product title when unmounted.
|
||||
* @param props - selected session title projection.
|
||||
* @returns no rendered content.
|
||||
* restore the original product title when unmounted.
|
||||
* @param props - Selected session title projection.
|
||||
* @returns No rendered content.
|
||||
*/
|
||||
export function DocumentTitle({ title }: DocumentTitleProps): null {
|
||||
const original = useRef(document.title)
|
||||
+8
-12
@@ -1,32 +1,28 @@
|
||||
/**
|
||||
* Real-UI assembly closure, invoked by the app-shell plugin once its inject
|
||||
* set is active: the whole layout tree hangs off the built-in 'root' slot
|
||||
* (ui-layout registers AppFrame there and renders the child slots
|
||||
* internally) — the shell's render is the one ctx-level renderSlot call in
|
||||
* the program.
|
||||
* Real-UI assembly closure. The whole layout tree hangs from the built-in
|
||||
* `root` slot, which is the only ctx-level slot render in the application.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { DocumentTitle } from './DocumentTitle.tsx'
|
||||
// Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program.
|
||||
import type {} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Assembly inputs: the active app-shell plugin ctx (slots/sessions/layout services provided). */
|
||||
/** Inputs available after the render service's inject set activates. */
|
||||
export interface AssemblyDeps {
|
||||
/** Client context with the assembly's inject set active. */
|
||||
/** Client context carrying the slots and sessions services. */
|
||||
ctx: Context
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the renderApp factory the app-shell plugin provides to AppRoot.
|
||||
* @param deps - assembly inputs.
|
||||
* @returns factory producing the real UI tree (called once per AppRoot render after settled).
|
||||
* Build the assembled application factory.
|
||||
* @param deps - Active render-service dependencies.
|
||||
* @returns Factory producing the application React tree.
|
||||
*/
|
||||
export function buildRenderApp(deps: AssemblyDeps): () => ReactNode {
|
||||
const { ctx } = deps
|
||||
const sessions = ctx.get('sessions')
|
||||
if (sessions === undefined) throw new Error('shell assembly: sessions service unavailable')
|
||||
if (sessions === undefined) throw new Error('render service: sessions service unavailable')
|
||||
const useSessions = bindSnapshotSelector(sessions.list)
|
||||
const SessionDocumentTitle = (): ReactNode => {
|
||||
const title = useSessions((state) => {
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Browser render service. It installs the slot renderer after its Cordis
|
||||
* dependencies activate and exposes the mount operation used by the web boot
|
||||
* kernel after the complete client roster settles.
|
||||
*/
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { buildRenderApp } from './app.tsx'
|
||||
|
||||
/** Mount operation exposed to the framework-free boot kernel. */
|
||||
export interface AppShellService {
|
||||
/**
|
||||
* Mount the assembled application into the supplied element.
|
||||
* @param container - Application mount point.
|
||||
* @returns Disposer that unmounts the React root.
|
||||
*/
|
||||
mount: (container: HTMLElement) => () => void
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Mount face provided after the render service activates. */
|
||||
appShell: AppShellService
|
||||
}
|
||||
}
|
||||
|
||||
/** Services required before application assembly. */
|
||||
export const inject = ['slots', 'sessions', 'layout']
|
||||
|
||||
/**
|
||||
* Install the slot renderer and provide the application mount face.
|
||||
* @param ctx - Plugin context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.slots.install(createSlotRenderer())
|
||||
ctx.reflect.provide('appShell', {
|
||||
mount: (container: HTMLElement): (() => void) => {
|
||||
const root = createRoot(container)
|
||||
root.render(buildRenderApp({ ctx })())
|
||||
return () => { root.unmount() }
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Host loader entry for the browser-only render service. */
|
||||
|
||||
/** Provides no host-side behavior. */
|
||||
export function apply(): void {}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-render-service`.
|
||||
* @module @deepseek-ai/dsh-client-render-service/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-render-service'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-render-service-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the package installs the render adapter and provides a
|
||||
* mount callback but owns no event stream or mutable cross-plugin data relation.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
+4
-11
@@ -1,15 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* buildRenderApp on SlotTestRuntime: the fail-loud sessions precondition, the
|
||||
* one ctx-level renderSlot('root') call, and the document-title projection
|
||||
* arms over the real slot stack.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { buildRenderApp } from '@deepseek-ai/dsh-client-web/src/app.tsx'
|
||||
import { buildRenderApp } from '../src/client/app.tsx'
|
||||
|
||||
let runtime: SlotTestRuntime | undefined
|
||||
|
||||
@@ -31,28 +26,26 @@ describe('buildRenderApp', () => {
|
||||
expect(() => buildRenderApp({ ctx: new Context() })).toThrow('sessions service unavailable')
|
||||
})
|
||||
|
||||
it('renders the root slot tree through the one ctx-level renderSlot call', async () => {
|
||||
it('renders the root slot tree', async () => {
|
||||
const b = await bench()
|
||||
const view = render(<>{b.renderApp()}</>)
|
||||
expect(view.getByTestId('frame')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('projects the current session durable title and falls back to the product title', async () => {
|
||||
it('projects the selected durable session title', async () => {
|
||||
document.title = 'Product'
|
||||
const b = await bench()
|
||||
render(<>{b.renderApp()}</>)
|
||||
// No current session: the product title stands.
|
||||
expect(document.title).toBe('Product')
|
||||
await b.runtime.sessions.add({ id: 's1', summary: { title: 'First' } })
|
||||
expect(document.title).toBe('First — Product')
|
||||
await b.runtime.sessions.setCurrent(undefined)
|
||||
expect(document.title).toBe('Product')
|
||||
// A session without a durable title keeps the product title.
|
||||
await b.runtime.sessions.add({ id: 's2' })
|
||||
expect(document.title).toBe('Product')
|
||||
})
|
||||
|
||||
it('a current id without a list row falls back (selection/list arbitration transient)', async () => {
|
||||
it('falls back when the selected id has no list row', async () => {
|
||||
document.title = 'Product'
|
||||
const b = await bench()
|
||||
await b.runtime.sessions.add({ id: 's1', summary: { title: 'First' } })
|
||||
+2
-5
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { DocumentTitle } from '../src/DocumentTitle.tsx'
|
||||
import { DocumentTitle } from '../src/client/DocumentTitle.tsx'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@@ -9,17 +9,14 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('DocumentTitle', () => {
|
||||
it('preserves the product title without a durable title and restores it on unmount', () => {
|
||||
it('projects a durable title and restores the product title', () => {
|
||||
document.title = 'DeepSeek Harness'
|
||||
const mounted = render(<DocumentTitle />)
|
||||
expect(document.title).toBe('DeepSeek Harness')
|
||||
|
||||
mounted.rerender(<DocumentTitle title="First title" />)
|
||||
expect(document.title).toBe('First title — DeepSeek Harness')
|
||||
|
||||
mounted.rerender(<DocumentTitle title="Revised title" />)
|
||||
expect(document.title).toBe('Revised title — DeepSeek Harness')
|
||||
|
||||
mounted.rerender(<DocumentTitle />)
|
||||
expect(document.title).toBe('DeepSeek Harness')
|
||||
mounted.unmount()
|
||||
@@ -0,0 +1,65 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { TestSessions, TestWorkspaces } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { Stabilizer } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import * as RenderService from '../src/client/index.ts'
|
||||
|
||||
const mounted: (() => void)[] = []
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { for (const unmount of mounted.splice(0)) unmount() })
|
||||
cleanup()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
const stabilize: Stabilizer = async (fn) => { await act(async () => { await fn() }) }
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
const slots = ctx.get('slots') as SlotRegistry
|
||||
ctx.provide('sessions', new TestSessions(stabilize, ctx))
|
||||
ctx.provide('workspaces', new TestWorkspaces(stabilize))
|
||||
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const fiber = ctx.plugin({ inject: [...RenderService.inject], apply: RenderService.apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber }
|
||||
}
|
||||
|
||||
function container(): HTMLElement {
|
||||
const el = document.createElement('div')
|
||||
document.body.append(el)
|
||||
return el
|
||||
}
|
||||
|
||||
describe('render service plugin', () => {
|
||||
it('installs the renderer and mounts the assembled application', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
slots.register({ name: 'root' }, () => <div data-testid="root-probe" />)
|
||||
const shell = ctx.get('appShell')
|
||||
expect(shell).toBeDefined()
|
||||
const el = container()
|
||||
act(() => { mounted.push(shell!.mount(el)) })
|
||||
expect(el.querySelector('[data-testid="root-probe"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('returns an unmount disposer', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
slots.register({ name: 'root' }, () => <div data-testid="root-probe" />)
|
||||
const el = container()
|
||||
let unmount: () => void = () => {}
|
||||
act(() => { unmount = ctx.get('appShell')!.mount(el) })
|
||||
act(() => { unmount() })
|
||||
expect(el.querySelector('[data-testid="root-probe"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('retracts the service and renderer with its fiber', async () => {
|
||||
const { ctx, slots, fiber } = await bench()
|
||||
await stabilize(() => fiber.dispose())
|
||||
expect(ctx.get('appShell')).toBeUndefined()
|
||||
expect(() => slots.renderSlot('root', {})).toThrow('not installed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-render-service', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -2,11 +2,11 @@
|
||||
* Shared tsdown preset for UI plugin client bundles. Emits a closure-factory
|
||||
* artifact: the bundle calls window.__ModuleLoader__.load({id, factory})
|
||||
* and resolves externals through the injected require (loader module table —
|
||||
* cordis DI entities, no globals, no import map). CSS Modules are compiled by
|
||||
* lightningcss inside the bundle: importing `x.module.css` yields the
|
||||
* hashed class map, and the css text auto-injects a <style data-plugin="<id>">
|
||||
* tag at factory execution (the loader removes plugin-owned tags on unload).
|
||||
* The virtual loader registers each real stylesheet as a watch dependency.
|
||||
* cordis DI entities, no globals, no import map). CSS is compiled by
|
||||
* lightningcss inside the bundle: `x.module.css` yields its hashed class map
|
||||
* and injects a tagged style at factory execution, while `x.css?inline`
|
||||
* exports compiled text for a plugin-owned lifecycle effect. The virtual
|
||||
* loaders register each real stylesheet as a watch dependency.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
@@ -22,7 +22,32 @@ import { PLATFORM_MODULES } from './web/src/platform.ts'
|
||||
* ending in `.css`, so the virtual id must not.
|
||||
*/
|
||||
const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
|
||||
const GLOBAL_CSS_VIRTUAL_PREFIX = '\0dsh-global-css:'
|
||||
const INLINE_CSS_VIRTUAL_PREFIX = '\0dsh-inline-css:'
|
||||
const CSS_VIRTUAL_SUFFIX = '.mjs'
|
||||
const INLINE_CSS_QUERY = '?inline'
|
||||
|
||||
/** Emit one plugin-owned style injector and an optional CSS Modules export. */
|
||||
function styleInjectionModule(
|
||||
id: string,
|
||||
fileId: string,
|
||||
css: string,
|
||||
classMap?: Readonly<Record<string, string>>,
|
||||
): string {
|
||||
const source = [
|
||||
`const css = ${JSON.stringify(css)};`,
|
||||
`const tagId = ${JSON.stringify(`${id}/${basename(fileId)}`)};`,
|
||||
'if (typeof document !== \'undefined\' && document.querySelector(\'style[data-plugin-css=\' + JSON.stringify(tagId) + \']\') === null) {',
|
||||
' const tag = document.createElement(\'style\');',
|
||||
` tag.dataset.plugin = ${JSON.stringify(id)};`,
|
||||
' tag.dataset.pluginCss = tagId;',
|
||||
' tag.textContent = css;',
|
||||
' document.head.appendChild(tag);',
|
||||
'}',
|
||||
]
|
||||
source.push(classMap === undefined ? 'export {};' : `export default ${JSON.stringify(classMap)};`)
|
||||
return source.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire/type layers a client bundle may inline: browser-safe contracts
|
||||
@@ -244,19 +269,38 @@ function clientConfig(id: string, entry: string): UserConfig {
|
||||
})
|
||||
const classMap: Record<string, string> = {}
|
||||
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
|
||||
// One <style data-plugin> per module file; idempotent under re-evaluation.
|
||||
return [
|
||||
`const css = ${JSON.stringify(code.toString())};`,
|
||||
`const tagId = ${JSON.stringify(`${id}/${basename(fileId)}`)};`,
|
||||
'if (typeof document !== \'undefined\' && document.querySelector(\'style[data-plugin-css=\' + JSON.stringify(tagId) + \']\') === null) {',
|
||||
' const tag = document.createElement(\'style\');',
|
||||
` tag.dataset.plugin = ${JSON.stringify(id)};`,
|
||||
' tag.dataset.pluginCss = tagId;',
|
||||
' tag.textContent = css;',
|
||||
' document.head.appendChild(tag);',
|
||||
'}',
|
||||
`export default ${JSON.stringify(classMap)};`,
|
||||
].join('\n')
|
||||
return styleInjectionModule(id, fileId, code.toString(), classMap)
|
||||
},
|
||||
}, {
|
||||
name: 'dsh-css-text-inline',
|
||||
resolveId(source: string, importer: string | undefined) {
|
||||
if (!source.endsWith(`.css${INLINE_CSS_QUERY}`)) return null
|
||||
const stylesheet = source.slice(0, -INLINE_CSS_QUERY.length)
|
||||
const abs = importer !== undefined ? sourceAssetPath(stylesheet, importer) : stylesheet
|
||||
return INLINE_CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
|
||||
},
|
||||
async load(virtualId: string) {
|
||||
if (!virtualId.startsWith(INLINE_CSS_VIRTUAL_PREFIX)) return null
|
||||
const fileId = virtualId.slice(INLINE_CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
|
||||
this.addWatchFile(fileId)
|
||||
const source = await readFile(fileId)
|
||||
const { code } = transform({ filename: fileId, code: source, minify: true })
|
||||
return `export default ${JSON.stringify(code.toString())};`
|
||||
},
|
||||
}, {
|
||||
name: 'dsh-css-global-inline',
|
||||
resolveId(source: string, importer: string | undefined) {
|
||||
if (!source.endsWith('.css') || source.endsWith('.module.css')) return null
|
||||
const abs = importer !== undefined ? sourceAssetPath(source, importer) : source
|
||||
return GLOBAL_CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
|
||||
},
|
||||
async load(virtualId: string) {
|
||||
if (!virtualId.startsWith(GLOBAL_CSS_VIRTUAL_PREFIX)) return null
|
||||
const fileId = virtualId.slice(GLOBAL_CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
|
||||
this.addWatchFile(fileId)
|
||||
const source = await readFile(fileId)
|
||||
const { code } = transform({ filename: fileId, code: source, minify: true })
|
||||
return styleInjectionModule(id, fileId, code.toString())
|
||||
},
|
||||
}],
|
||||
outputOptions: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Pure React attachment atoms (zero cordis): the composer draft-image rail (`AttachmentRail`), the chat-history image gallery (`MessageImage`/`ImageGallery`), the original-image lightbox (`ImageLightbox`), and the full-page drop overlay (`DropOverlay`). Every string arrives through label props resolved by the owning plugin's own locale namespace, and nothing here reads application state; `@deepseek-ai/dsh-client-ui-conversation` is the current consumer, bridging its `conversation` dictionary through its `image-labels` module.
|
||||
Dynamic attachment presentation plugin for the conversation UI. It waits for the conversation package's `conversation.input.attachments` and `conversation.message.images` declarations through `ctx.slots.inject`, then registers the composer draft-image rail, document drop target, chat-history image gallery, and original-image lightbox. The conversation slot owner supplies attachment data, image loading, callbacks, and its namespace translator; presentation components remain pure props and are not exported from the package entry.
|
||||
|
||||
## Attachment rail
|
||||
|
||||
@@ -18,7 +18,7 @@ Pure React attachment atoms (zero cordis): the composer draft-image rail (`Attac
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the package renders pure React atoms in the browser; nothing here reaches a model request.
|
||||
None. The plugin renders attachment state supplied by the conversation UI and contributes no model-visible input.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -28,4 +28,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
- **Images only** — non-image files have no rail card or history renderer yet; DeepSeek Chat-style file cards and upload-progress states wait until the composer accepts non-image attachments.
|
||||
- **No zoom or download in the lightbox** — the preview renders the original at fit-to-viewport size only.
|
||||
- **The lightbox does not trap focus** — it sets `aria-modal` and restores focus on close, but Tab can reach the page behind it (behavior carried over from the pre-package component).
|
||||
- **The lightbox does not trap focus** — it sets `aria-modal` and restores focus on close, but Tab can reach the page behind it.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
纯 React 附件原子组件(零 cordis):输入框草稿图片栏(`AttachmentRail`)、聊天历史图片画廊(`MessageImage`/`ImageGallery`)、原图灯箱(`ImageLightbox`)与整页拖放遮罩(`DropOverlay`)。所有文案都由持有方插件在自己的语言命名空间中解析后经 label props 传入,此包不读取任何应用状态;当前消费者是 `@deepseek-ai/dsh-client-ui-conversation`,经其 `image-labels` 模块桥接 `conversation` 词典。
|
||||
对话 UI 的动态附件呈现插件。它通过 `ctx.slots.inject` 等待 conversation 包声明 `conversation.input.attachments` 与 `conversation.message.images`,随后注册输入框草稿图片栏、文档拖放目标、聊天历史图片画廊和原图灯箱。conversation slot 持有方提供附件数据、图片加载、回调及其命名空间翻译器;呈现组件保持纯 props,且不从包入口导出。
|
||||
|
||||
## 附件栏
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该包(package)在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。
|
||||
无。插件渲染由对话 UI 提供的附件状态,不贡献模型可见输入。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -28,4 +28,4 @@
|
||||
|
||||
- **仅支持图片** — 非图片文件尚无附件栏卡片与历史渲染;DeepSeek Chat 风格的文件卡片和上传进度状态等输入框接受非图片附件后再做。
|
||||
- **灯箱无缩放与下载** — 预览仅以适配视口的尺寸渲染原图。
|
||||
- **灯箱不锁定焦点** — 它设置 `aria-modal` 并在关闭时归还焦点,但 Tab 仍可移动到背后的页面(沿袭入包前组件的行为)。
|
||||
- **灯箱不锁定焦点** — 它设置 `aria-modal` 并在关闭时归还焦点,但 Tab 仍可移动到背后的页面。
|
||||
|
||||
@@ -28,25 +28,29 @@ export function ComposerAttachments({
|
||||
}, [attachments, preview])
|
||||
|
||||
useEffect(() => {
|
||||
const hasFiles = (event: globalThis.DragEvent): boolean =>
|
||||
event.dataTransfer?.types.includes('Files') ?? false
|
||||
const fileTransfer = (event: globalThis.DragEvent): DataTransfer | null => {
|
||||
const dataTransfer = event.dataTransfer
|
||||
if (dataTransfer === null || !dataTransfer.types.includes('Files')) return null
|
||||
return dataTransfer
|
||||
}
|
||||
const reset = (): void => {
|
||||
dragDepth.current = 0
|
||||
setDragActive(false)
|
||||
}
|
||||
const onDragEnter = (event: globalThis.DragEvent): void => {
|
||||
if (!hasFiles(event)) return
|
||||
if (fileTransfer(event) === null) return
|
||||
event.preventDefault()
|
||||
dragDepth.current += 1
|
||||
setDragActive(true)
|
||||
}
|
||||
const onDragOver = (event: globalThis.DragEvent): void => {
|
||||
if (!hasFiles(event) || event.dataTransfer === null) return
|
||||
const dataTransfer = fileTransfer(event)
|
||||
if (dataTransfer === null) return
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none'
|
||||
dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none'
|
||||
}
|
||||
const onDragLeave = (event: globalThis.DragEvent): void => {
|
||||
if (!hasFiles(event)) return
|
||||
if (fileTransfer(event) === null) return
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1)
|
||||
if (dragDepth.current === 0) setDragActive(false)
|
||||
const leftViewport = event.clientX <= 0 || event.clientY <= 0
|
||||
@@ -54,10 +58,11 @@ export function ComposerAttachments({
|
||||
if ((event.target === document.documentElement || event.target === document.body) && leftViewport) reset()
|
||||
}
|
||||
const onDrop = (event: globalThis.DragEvent): void => {
|
||||
if (!hasFiles(event)) return
|
||||
const dataTransfer = fileTransfer(event)
|
||||
if (dataTransfer === null) return
|
||||
event.preventDefault()
|
||||
reset()
|
||||
if (canAcceptDrop) onAddImages([...(event.dataTransfer?.files ?? [])])
|
||||
if (canAcceptDrop) onAddImages([...dataTransfer.files])
|
||||
}
|
||||
document.addEventListener('dragenter', onDragEnter)
|
||||
document.addEventListener('dragover', onDragOver)
|
||||
|
||||
@@ -4,12 +4,20 @@ import type { DropOverlayLabels } from '../DropOverlay.tsx'
|
||||
import type { ImageLightboxLabels } from '../ImageLightbox.tsx'
|
||||
import type { MessageImageLabels } from '../MessageImage.tsx'
|
||||
|
||||
/** Resolve original-image lightbox strings from the conversation namespace. */
|
||||
/**
|
||||
* Resolve original-image lightbox strings from the conversation namespace.
|
||||
* @param t - conversation namespace translator.
|
||||
* @returns translated lightbox labels.
|
||||
*/
|
||||
export function lightboxLabels(t: TranslateNS<'conversation'>): ImageLightboxLabels {
|
||||
return { dialog: t('image.preview'), close: t('image.closePreview') }
|
||||
}
|
||||
|
||||
/** Resolve historical message-image strings from the conversation namespace. */
|
||||
/**
|
||||
* Resolve historical message-image strings from the conversation namespace.
|
||||
* @param t - conversation namespace translator.
|
||||
* @returns translated message-image labels.
|
||||
*/
|
||||
export function messageImageLabels(t: TranslateNS<'conversation'>): MessageImageLabels {
|
||||
return {
|
||||
image: t('image.label'),
|
||||
@@ -21,7 +29,13 @@ export function messageImageLabels(t: TranslateNS<'conversation'>): MessageImage
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the document-level drop invitation and its optional limits line. */
|
||||
/**
|
||||
* Resolve the document-level drop invitation and its optional limits line.
|
||||
* @param t - conversation namespace translator.
|
||||
* @param accepting - whether the composer can accept dropped files.
|
||||
* @param limits - optional translated count and size values.
|
||||
* @returns translated drop-overlay labels.
|
||||
*/
|
||||
export function dropOverlayLabels(
|
||||
t: TranslateNS<'conversation'>,
|
||||
accepting: boolean,
|
||||
@@ -34,7 +48,11 @@ export function dropOverlayLabels(
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve draft-image rail strings from the conversation namespace. */
|
||||
/**
|
||||
* Resolve draft-image rail strings from the conversation namespace.
|
||||
* @param t - conversation namespace translator.
|
||||
* @returns translated attachment-rail labels.
|
||||
*/
|
||||
export function attachmentRailLabels(t: TranslateNS<'conversation'>): AttachmentRailLabels {
|
||||
return {
|
||||
group: t('image.pending'),
|
||||
|
||||
@@ -110,6 +110,15 @@ describe('AttachmentRail', () => {
|
||||
expect(view.getByLabelText('向右滚动图片')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps scrolling available when ResizeObserver is unavailable', () => {
|
||||
vi.stubGlobal('ResizeObserver', undefined)
|
||||
const view = render(
|
||||
<AttachmentRail items={[item('a')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
)
|
||||
expect(view.getByRole('group', { name: '待发送图片' })).toBeTruthy()
|
||||
view.unmount()
|
||||
})
|
||||
|
||||
it('pans horizontally on a vertical wheel, consuming the event, with clamped normalized travel', () => {
|
||||
const view = render(
|
||||
<AttachmentRail items={[item('a'), item('b')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type {
|
||||
ComposerAttachment, ComposerAttachmentsOwnerProps, ComposerAttachmentsProps,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { ComposerAttachments } from '../src/client/ComposerAttachments.tsx'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
const t = ((key: string, params?: Readonly<Record<string, unknown>>): string => {
|
||||
const messages: Record<string, string> = {
|
||||
'image.pending': '待发送图片',
|
||||
'image.original': '原图',
|
||||
'image.preview': '原图预览',
|
||||
'image.closePreview': '关闭原图预览',
|
||||
'image.openOriginal': '查看原图',
|
||||
'image.scrollLeft': '向左滚动图片',
|
||||
'image.scrollRight': '向右滚动图片',
|
||||
'image.dropBlocked': '当前无法添加图片',
|
||||
'image.dropTitle': '图片拖动到此处即可添加',
|
||||
}
|
||||
if (key === 'image.remove') {
|
||||
const name = params?.name
|
||||
return `移除图片 ${typeof name === 'string' ? name : ''}`
|
||||
}
|
||||
if (key === 'image.dropDesc') {
|
||||
const count = params?.count
|
||||
const size = params?.size
|
||||
return `最多 ${typeof count === 'number' ? String(count) : ''} 张,每张 ${typeof size === 'string' ? size : ''}`
|
||||
}
|
||||
return messages[key] ?? key
|
||||
}) as ComposerAttachmentsProps['t']
|
||||
|
||||
function attachment(id: string, name = `${id}.png`): ComposerAttachment {
|
||||
return {
|
||||
kind: 'image',
|
||||
id: id as ComposerAttachment['id'],
|
||||
file: new File([Uint8Array.of(1)], name, { type: 'image/png' }),
|
||||
previewUrl: `blob:${id}`,
|
||||
}
|
||||
}
|
||||
|
||||
function props(overrides: Partial<ComposerAttachmentsOwnerProps> = {}): ComposerAttachmentsProps {
|
||||
return {
|
||||
attachments: [],
|
||||
canAcceptDrop: true,
|
||||
onAddImages: () => {},
|
||||
onRemoveImage: () => {},
|
||||
t,
|
||||
...overrides,
|
||||
} as unknown as ComposerAttachmentsProps
|
||||
}
|
||||
|
||||
describe('ComposerAttachments', () => {
|
||||
it('accepts file drops anywhere on the document and keeps non-file drags native', () => {
|
||||
const onAddImages = vi.fn()
|
||||
const view = render(<ComposerAttachments {...props({
|
||||
onAddImages,
|
||||
dropLimits: { count: 20, size: '5MB' },
|
||||
})} />)
|
||||
|
||||
expect(fireEvent.dragEnter(document.body, { dataTransfer: null })).toBe(true)
|
||||
const textTransfer = { types: ['text/plain'], files: [], dropEffect: 'none' }
|
||||
expect(fireEvent.dragEnter(document.body, { dataTransfer: textTransfer })).toBe(true)
|
||||
expect(fireEvent.dragOver(document.body, { dataTransfer: textTransfer })).toBe(true)
|
||||
expect(fireEvent.drop(document.body, { dataTransfer: textTransfer })).toBe(true)
|
||||
expect(view.queryByRole('status')).toBeNull()
|
||||
|
||||
const image = attachment('dropped').file
|
||||
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
|
||||
expect(fireEvent.dragEnter(document.body, { dataTransfer })).toBe(false)
|
||||
expect(view.getByRole('status').textContent).toContain('图片拖动到此处即可添加')
|
||||
expect(view.getByRole('status').textContent).toContain('最多 20 张,每张 5MB')
|
||||
expect(fireEvent.dragOver(document.body, { dataTransfer })).toBe(false)
|
||||
expect(dataTransfer.dropEffect).toBe('copy')
|
||||
expect(fireEvent.drop(document.body, { dataTransfer })).toBe(false)
|
||||
expect(onAddImages).toHaveBeenCalledWith([image])
|
||||
expect(view.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('tracks nested file drags and clears an aborted drag', () => {
|
||||
const view = render(<ComposerAttachments {...props()} />)
|
||||
const dataTransfer = { types: ['Files'], files: [], dropEffect: 'none' }
|
||||
fireEvent.dragLeave(document.body, {
|
||||
dataTransfer: { types: ['text/plain'], files: [], dropEffect: 'none' },
|
||||
})
|
||||
fireEvent.dragEnter(document.body, { dataTransfer })
|
||||
fireEvent.dragEnter(document.body, { dataTransfer })
|
||||
fireEvent.dragLeave(document.body, { dataTransfer, clientX: 5, clientY: 5 })
|
||||
expect(view.getByRole('status')).toBeTruthy()
|
||||
fireEvent.dragLeave(document.body, { dataTransfer, clientX: 5, clientY: 5 })
|
||||
expect(view.queryByRole('status')).toBeNull()
|
||||
fireEvent.dragEnter(document.documentElement, { dataTransfer })
|
||||
const leftViewport = new Event('dragleave', { bubbles: true, cancelable: true })
|
||||
Object.defineProperties(leftViewport, {
|
||||
dataTransfer: { value: dataTransfer },
|
||||
clientX: { value: -1 },
|
||||
clientY: { value: 5 },
|
||||
})
|
||||
fireEvent(document.documentElement, leftViewport)
|
||||
expect(view.queryByRole('status')).toBeNull()
|
||||
fireEvent.dragEnter(document.body, { dataTransfer })
|
||||
fireEvent.dragEnd(window, { dataTransfer })
|
||||
expect(view.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a blocked drop without forwarding its files', () => {
|
||||
const onAddImages = vi.fn()
|
||||
const view = render(<ComposerAttachments {...props({ canAcceptDrop: false, onAddImages })} />)
|
||||
const image = attachment('blocked').file
|
||||
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' }
|
||||
fireEvent.dragEnter(document.body, { dataTransfer })
|
||||
expect(view.getByRole('status').textContent).toBe('当前无法添加图片')
|
||||
fireEvent.dragOver(document.body, { dataTransfer })
|
||||
expect(dataTransfer.dropEffect).toBe('none')
|
||||
fireEvent.drop(document.body, { dataTransfer })
|
||||
expect(onAddImages).not.toHaveBeenCalled()
|
||||
expect(view.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('routes rail removal and closes previews on Escape or attachment removal', () => {
|
||||
const onRemoveImage = vi.fn()
|
||||
const image = attachment('draft-1', 'pixel.png')
|
||||
const initial = props({ attachments: [image], onRemoveImage })
|
||||
const view = render(<ComposerAttachments {...initial} />)
|
||||
|
||||
fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
|
||||
expect(onRemoveImage).toHaveBeenCalledWith(image.id)
|
||||
fireEvent.click(view.getByTitle('查看原图'))
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
view.rerender(<ComposerAttachments {...props({ attachments: [], onRemoveImage })} />)
|
||||
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
||||
|
||||
view.rerender(<ComposerAttachments {...initial} />)
|
||||
fireEvent.click(view.getByTitle('查看原图'))
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
||||
})
|
||||
|
||||
it('labels an unnamed attachment and its original-image preview', () => {
|
||||
const image = attachment('unnamed', '')
|
||||
const view = render(<ComposerAttachments {...props({ attachments: [image] })} />)
|
||||
expect(view.getByAltText('待发送图片')).toBeTruthy()
|
||||
fireEvent.click(view.getByTitle('查看原图'))
|
||||
expect(view.getByAltText('原图')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -3,8 +3,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { MessageImagesProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { ImageGallery, MessageImage } from '../src/MessageImage.tsx'
|
||||
import type { MessageImageLabels } from '../src/MessageImage.tsx'
|
||||
import { MessageImages } from '../src/client/MessageImages.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -154,4 +156,58 @@ describe('ImageGallery', () => {
|
||||
)
|
||||
expect(several.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('renders the conversation slot entry with translated labels', async () => {
|
||||
const t = ((key: string, params?: Readonly<Record<string, unknown>>) => {
|
||||
const translated: Record<string, string> = {
|
||||
'image.label': '图片',
|
||||
'image.openOriginal': '查看原图',
|
||||
'image.loading': '图片加载中…',
|
||||
'image.loadFailed': '图片加载失败,点击重试',
|
||||
'image.preview': '原图预览',
|
||||
'image.closePreview': '关闭原图预览',
|
||||
}
|
||||
if (key === 'image.openOriginalLabel') {
|
||||
const label = params?.label
|
||||
return `${typeof label === 'string' ? label : ''},点击查看原图`
|
||||
}
|
||||
return translated[key] ?? key
|
||||
}) as MessageImagesProps['t']
|
||||
const loadImage = vi.fn().mockResolvedValue('blob:slot-image')
|
||||
const useSession: MessageImagesProps['useSession'] = () => {
|
||||
throw new Error('MessageImages does not read the session snapshot')
|
||||
}
|
||||
const useInput: MessageImagesProps['useInput'] = () => {
|
||||
throw new Error('MessageImages does not read the input snapshot')
|
||||
}
|
||||
const useSessions: MessageImagesProps['useSessions'] = () => {
|
||||
throw new Error('MessageImages does not read the session list snapshot')
|
||||
}
|
||||
const useWorkspaces: MessageImagesProps['useWorkspaces'] = () => {
|
||||
throw new Error('MessageImages does not read the workspace list snapshot')
|
||||
}
|
||||
const props: MessageImagesProps = {
|
||||
sessionId: 'message-images-test' as MessageImagesProps['sessionId'],
|
||||
useSession,
|
||||
useSessions,
|
||||
useWorkspaces,
|
||||
useProjection: () => undefined,
|
||||
useInput,
|
||||
inputActions: {
|
||||
setDraft: vi.fn(),
|
||||
addImages: vi.fn(() => true),
|
||||
removeImage: vi.fn(),
|
||||
pruneImages: vi.fn(),
|
||||
submit: vi.fn(),
|
||||
},
|
||||
images: [{ attachment }],
|
||||
loadImage,
|
||||
align: 'end',
|
||||
t,
|
||||
}
|
||||
const view = render(<MessageImages {...props} />)
|
||||
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
||||
expect(view.getByRole('button', { name: 'history.png,点击查看原图' })).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-align="end"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { apply as applyHost } from '../src/index.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { ComposerAttachments } from '../src/client/ComposerAttachments.tsx'
|
||||
import { MessageImages } from '../src/client/MessageImages.tsx'
|
||||
|
||||
describe('attachment plugin', () => {
|
||||
it('keeps the host half empty', () => {
|
||||
expect(() => { applyHost() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('registers the composer and historical-image slot entries', () => {
|
||||
const registered: Array<{ spec: unknown; component: unknown }> = []
|
||||
const register = vi.fn((spec: unknown, component: unknown) => {
|
||||
registered.push({ spec, component })
|
||||
return () => {}
|
||||
})
|
||||
const injectSlot = vi.fn((_name: string, setup: () => unknown) => setup())
|
||||
|
||||
apply({ slots: { inject: injectSlot, register } } as never)
|
||||
|
||||
expect(inject).toEqual(['slots'])
|
||||
expect(injectSlot.mock.calls.map(([name]) => name)).toEqual([
|
||||
'conversation.input.attachments',
|
||||
'conversation.message.images',
|
||||
])
|
||||
expect(registered).toEqual([
|
||||
{
|
||||
spec: { name: 'conversation.input.attachments', locale: 'conversation' },
|
||||
component: ComposerAttachments,
|
||||
},
|
||||
{
|
||||
spec: { name: 'conversation.message.images', locale: 'conversation' },
|
||||
component: MessageImages,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -63,7 +63,7 @@ function MessageItem({ node, t: translate }: MessageItemProps) {
|
||||
visibility: 'visible',
|
||||
data: node.kind === 'model-retry' ? { attempts: [node], current: node } : node,
|
||||
}
|
||||
const props = { node: viewNode, t: translate } as ChatNodeViewProps
|
||||
const props = { node: viewNode, t: translate, renderMessageImages } as ChatNodeViewProps
|
||||
switch (node.kind) {
|
||||
case 'user':
|
||||
case 'steering':
|
||||
|
||||
@@ -14,7 +14,9 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SessionInputShell } from '../src/client/input/facade.ts'
|
||||
import type { ComposerAttachment } from '../src/client/contract/slots.ts'
|
||||
import type {
|
||||
ComposerAttachment, ComposerAttachmentsOwnerProps,
|
||||
} from '../src/client/contract/slots.ts'
|
||||
import type { DraftAttachmentId } from '../src/client/input/contract.ts'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
@@ -204,6 +206,12 @@ function bench(over?: BenchOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function attachmentOwner(slotCalls: readonly { key: string; owner: unknown }[]): ComposerAttachmentsOwnerProps {
|
||||
const call = slotCalls.find(candidate => candidate.key === 'conversation.input.attachments')
|
||||
if (call === undefined) throw new Error('attachment slot was not rendered')
|
||||
return call.owner as ComposerAttachmentsOwnerProps
|
||||
}
|
||||
|
||||
describe('image draft rail', () => {
|
||||
it('collects clipboard files while preserving text from a mixed paste', () => {
|
||||
const addImages = vi.fn(() => null)
|
||||
@@ -222,41 +230,6 @@ describe('image draft rail', () => {
|
||||
expect(shell.snapshot.draft).toBe('同时粘贴的文字')
|
||||
})
|
||||
|
||||
it('accepts a drop anywhere on the page under the full-page overlay', () => {
|
||||
const addImages = vi.fn(() => null)
|
||||
const { view } = bench({ addImages })
|
||||
const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' })
|
||||
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
|
||||
// The drag never touches the composer card: the listeners are page-wide.
|
||||
expect(fireEvent.dragEnter(document.body, { dataTransfer })).toBe(false)
|
||||
expect(view.getByRole('status').textContent).toContain('图片拖动到此处即可添加')
|
||||
expect(fireEvent.dragOver(document.body, { dataTransfer })).toBe(false)
|
||||
expect(dataTransfer.dropEffect).toBe('copy')
|
||||
expect(fireEvent.drop(document.body, { dataTransfer })).toBe(false)
|
||||
expect(addImages).toHaveBeenCalledWith([image])
|
||||
expect(view.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps text drags native and hides the overlay when the drag leaves or ends', () => {
|
||||
const addImages = vi.fn(() => null)
|
||||
const { view } = bench({ addImages })
|
||||
// A text drag carries no Files type: no overlay, native behavior stays.
|
||||
fireEvent.dragEnter(document.body, { dataTransfer: { types: ['text/plain'], files: [], dropEffect: 'none' } })
|
||||
expect(view.queryByRole('status')).toBeNull()
|
||||
const dataTransfer = { types: ['Files'], files: [], dropEffect: 'none' }
|
||||
fireEvent.dragEnter(document.body, { dataTransfer })
|
||||
expect(view.getByRole('status')).toBeTruthy()
|
||||
fireEvent.dragLeave(document.body, { dataTransfer })
|
||||
expect(view.queryByRole('status')).toBeNull()
|
||||
// An aborted drag (Escape) fires dragend without a balancing leave.
|
||||
fireEvent.dragEnter(document.body, { dataTransfer })
|
||||
fireEvent.dragEnter(document.querySelector('textarea')!, { dataTransfer })
|
||||
expect(view.getByRole('status')).toBeTruthy()
|
||||
fireEvent.dragEnd(window, { dataTransfer })
|
||||
expect(view.queryByRole('status')).toBeNull()
|
||||
expect(addImages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pre-checks projected limits at intake: whole-batch refusal with product copy, none added', () => {
|
||||
const limits = {
|
||||
maxImageBytes: 1024 * 1024,
|
||||
@@ -266,18 +239,18 @@ describe('image draft rail', () => {
|
||||
mediaTypes: ['image/png'] as const,
|
||||
}
|
||||
const png = (bytes: number, name: string) => new File([new ArrayBuffer(bytes)], name, { type: 'image/png' })
|
||||
const drop = (files: File[]) => {
|
||||
fireEvent.drop(document.body, { dataTransfer: { types: ['Files'], files, dropEffect: 'none' } })
|
||||
const intake = (result: ReturnType<typeof bench>, files: File[]) => {
|
||||
act(() => { attachmentOwner(result.slotCalls).onAddImages(files) })
|
||||
}
|
||||
// Count: three at once over a two-image limit → the whole batch refused.
|
||||
const overCount = bench({ addImages: vi.fn(() => null), imageLimits: limits })
|
||||
drop([png(8, 'a.png'), png(8, 'b.png'), png(8, 'c.png')])
|
||||
intake(overCount, [png(8, 'a.png'), png(8, 'b.png'), png(8, 'c.png')])
|
||||
expect(overCount.view.getByRole('alert').textContent).toContain('一条消息最多添加 2 张图片')
|
||||
expect(overCount.props.addImages).not.toHaveBeenCalled()
|
||||
cleanup()
|
||||
// Per-file bytes.
|
||||
const overFile = bench({ addImages: vi.fn(() => null), imageLimits: limits })
|
||||
drop([png(1024 * 1024 + 1, 'big.png')])
|
||||
intake(overFile, [png(1024 * 1024 + 1, 'big.png')])
|
||||
expect(overFile.view.getByRole('alert').textContent).toContain('单张图片不能超过 1MB')
|
||||
expect(overFile.props.addImages).not.toHaveBeenCalled()
|
||||
cleanup()
|
||||
@@ -285,21 +258,21 @@ describe('image draft rail', () => {
|
||||
const held = new File([new ArrayBuffer(1024 * 1024 * 1.5)], 'held.png', { type: 'image/png' })
|
||||
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file: held, previewUrl: 'blob:held' }
|
||||
const overTotal = bench({ addImages: vi.fn(() => null), imageLimits: limits, attachments: [attachment] })
|
||||
drop([png(1024 * 1024, 'more.png')])
|
||||
intake(overTotal, [png(1024 * 1024, 'more.png')])
|
||||
expect(overTotal.view.getByRole('alert').textContent).toContain('图片总大小超过 2MB')
|
||||
expect(overTotal.props.addImages).not.toHaveBeenCalled()
|
||||
cleanup()
|
||||
// Within every limit: the batch passes through to addImages.
|
||||
const within = bench({ addImages: vi.fn(() => null), imageLimits: limits })
|
||||
const fits = png(16, 'fits.png')
|
||||
drop([fits])
|
||||
intake(within, [fits])
|
||||
expect(within.props.addImages).toHaveBeenCalledWith([fits])
|
||||
expect(within.view.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('announces the format problem before any limit when the batch holds a non-image', () => {
|
||||
const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||||
const { view } = bench({
|
||||
const result = bench({
|
||||
addImages,
|
||||
imageLimits: {
|
||||
maxImageBytes: 8,
|
||||
@@ -314,13 +287,13 @@ describe('image draft rail', () => {
|
||||
new File([new ArrayBuffer(64)], 'a.pdf', { type: 'application/pdf' }),
|
||||
new File([new ArrayBuffer(64)], 'b.pdf', { type: 'application/pdf' }),
|
||||
]
|
||||
fireEvent.drop(document.body, { dataTransfer: { types: ['Files'], files, dropEffect: 'none' } })
|
||||
act(() => { attachmentOwner(result.slotCalls).onAddImages(files) })
|
||||
expect(addImages).toHaveBeenCalledWith(files)
|
||||
expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||||
expect(result.view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
|
||||
})
|
||||
|
||||
it('shows the projected limits in the drop overlay desc line', () => {
|
||||
const { view } = bench({
|
||||
it('projects display-ready limits into the attachment slot', () => {
|
||||
const result = bench({
|
||||
addImages: vi.fn(() => null),
|
||||
imageLimits: {
|
||||
maxImageBytes: 5 * 1024 * 1024,
|
||||
@@ -330,8 +303,7 @@ describe('image draft rail', () => {
|
||||
mediaTypes: ['image/png'] as const,
|
||||
},
|
||||
})
|
||||
fireEvent.dragEnter(document.body, { dataTransfer: { types: ['Files'], files: [], dropEffect: 'none' } })
|
||||
expect(view.getByRole('status').textContent).toContain('最多 20 张,每张 5MB')
|
||||
expect(attachmentOwner(result.slotCalls).dropLimits).toEqual({ count: 20, size: '5MB' })
|
||||
})
|
||||
|
||||
it('announces server attachment rejections as product copy, other codes as developer text', () => {
|
||||
@@ -351,41 +323,25 @@ describe('image draft rail', () => {
|
||||
expect(other.view.getByRole('alert').textContent).toContain('boom (internal)')
|
||||
})
|
||||
|
||||
it('shows the blocked overlay and refuses the drop while the composer is locked', () => {
|
||||
const addImages = vi.fn(() => null)
|
||||
const { view } = bench({ addImages, inert: true })
|
||||
const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' })
|
||||
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' }
|
||||
fireEvent.dragEnter(document.body, { dataTransfer })
|
||||
expect(view.getByRole('status').textContent).toContain('当前无法添加图片')
|
||||
fireEvent.dragOver(document.body, { dataTransfer })
|
||||
expect(dataTransfer.dropEffect).toBe('none')
|
||||
fireEvent.drop(document.body, { dataTransfer })
|
||||
expect(addImages).not.toHaveBeenCalled()
|
||||
expect(view.queryByRole('status')).toBeNull()
|
||||
it('marks the attachment slot unavailable while the composer is locked', () => {
|
||||
const result = bench({ addImages: vi.fn(() => null), inert: true })
|
||||
expect(attachmentOwner(result.slotCalls).canAcceptDrop).toBe(false)
|
||||
})
|
||||
|
||||
it('sends an image-only draft and removes its thumbnail', () => {
|
||||
it('sends an image-only draft and exposes removal through the attachment slot', () => {
|
||||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
|
||||
const { view, textarea, sink, removeImage } = bench({ attachments: [attachment] })
|
||||
const result = bench({ attachments: [attachment] })
|
||||
const { view, textarea, sink, removeImage } = result
|
||||
expect((view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue')
|
||||
fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
|
||||
const owner = attachmentOwner(result.slotCalls)
|
||||
expect(owner.attachments).toEqual([attachment])
|
||||
owner.onRemoveImage(attachment.id)
|
||||
expect(removeImage).toHaveBeenCalledWith('draft-1')
|
||||
})
|
||||
|
||||
it('opens the original image on a single click and closes it with Escape', () => {
|
||||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
|
||||
const { view } = bench({ attachments: [attachment] })
|
||||
fireEvent.click(view.getByTitle('查看原图'))
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
||||
})
|
||||
|
||||
it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -411,13 +367,15 @@ describe('image draft rail', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('announces a rejected drop through the same toast', () => {
|
||||
it('announces a rejected attachment-slot intake through the same toast', () => {
|
||||
const addImages = vi.fn(() => '图片读取服务不可用')
|
||||
const { view } = bench({ addImages })
|
||||
const card = view.container.querySelector('[class*="card"]')!
|
||||
const dataTransfer = { types: ['Files'], files: [new File([Uint8Array.of(1)], 'x.png', { type: 'image/png' })], dropEffect: 'none' }
|
||||
fireEvent.drop(card, { dataTransfer })
|
||||
expect(view.getByRole('alert').textContent).toContain('图片读取服务不可用')
|
||||
const result = bench({ addImages })
|
||||
act(() => {
|
||||
attachmentOwner(result.slotCalls).onAddImages([
|
||||
new File([Uint8Array.of(1)], 'x.png', { type: 'image/png' }),
|
||||
])
|
||||
})
|
||||
expect(result.view.getByRole('alert').textContent).toContain('图片读取服务不可用')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1271,7 +1229,7 @@ describe('command launcher chrome and control seats', () => {
|
||||
expect(view.queryByLabelText(/^访问模式/)).toBeNull()
|
||||
// Every seat dispatched, nothing rendered.
|
||||
expect(slotCalls.map(c => c.key)).toEqual([
|
||||
'conversation.input.plan', 'conversation.input.model',
|
||||
'conversation.input.attachments', 'conversation.input.plan', 'conversation.input.model',
|
||||
])
|
||||
expect(view.queryByLabelText('Plan mode')).toBeNull()
|
||||
expect(view.queryByLabelText('Model')).toBeNull()
|
||||
@@ -1418,10 +1376,14 @@ describe('command launcher chrome and control seats', () => {
|
||||
expect(view.getByTestId('plan-entry')).toBeTruthy()
|
||||
expect(view.getByTestId('model-entry')).toBeTruthy()
|
||||
// The bar hands its chrome disable state to the filling entry.
|
||||
expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked)).toBe(true)
|
||||
const controls = slotCalls.filter(call => call.key !== 'conversation.input.attachments')
|
||||
expect(controls.every(c => (c.owner as { locked: boolean }).locked)).toBe(true)
|
||||
expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(false)
|
||||
cleanup()
|
||||
const live = bench({ running: true })
|
||||
expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
|
||||
const liveControls = live.slotCalls.filter(call => call.key !== 'conversation.input.attachments')
|
||||
expect(liveControls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
|
||||
expect(attachmentOwner(live.slotCalls).canAcceptDrop).toBe(true)
|
||||
})
|
||||
|
||||
it('disabled locks the Access chip and command launcher (running does not)', () => {
|
||||
|
||||
@@ -43,6 +43,7 @@ interface ConstChoice {
|
||||
/**
|
||||
* Read the dynamic preset enum encoded by the host's `defaultPreset` schema.
|
||||
* @param view - permission namespace descriptor.
|
||||
* @param schema - settings schema operations.
|
||||
* @returns current value and selectable options.
|
||||
*/
|
||||
export function permissionDefaultOf(view: SettingsNamespaceView, schema: SettingsSchemaService): {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SlotRegistry, type SessionId } from '@deepseek-ai/dsh-client-runtime/cl
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-commands/client'
|
||||
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client'
|
||||
import {
|
||||
PermissionRow, type PermissionRowInjected,
|
||||
@@ -41,6 +42,7 @@ async function bench() {
|
||||
// The plugin injects `remote`; forwarded events reach it through the same
|
||||
// `$dispatch` handoff the connection sink makes.
|
||||
new TestRemote(ctx)
|
||||
new SettingsSchemaService(ctx)
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
|
||||
@@ -76,6 +76,7 @@ export function deriveKeyRef(provider: string): string {
|
||||
* the choices the page offers cannot drift from the ones the adapter accepts:
|
||||
* both come from the same `Config`.
|
||||
* @param namespace - the namespace view whose schema declares the profile shape.
|
||||
* @param schema - settings schema operations.
|
||||
* @returns the protocol identifiers, or an empty list when the schema has none.
|
||||
*/
|
||||
export function protocolChoices(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client'
|
||||
import { ModelsSection } from '../src/client/ModelsSection.tsx'
|
||||
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
|
||||
@@ -25,6 +26,7 @@ async function bench(isLoopback = true) {
|
||||
// The apply path only captures the wire face; no call leaves this fake
|
||||
// until a section actually loads.
|
||||
ctx.provide('connection', { api: {}, isLoopback } as never)
|
||||
new SettingsSchemaService(ctx)
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale }
|
||||
}
|
||||
|
||||
@@ -43,7 +45,7 @@ function declare(slots: SlotRegistry): () => void {
|
||||
|
||||
describe('ui-settings-models apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsSchema'])
|
||||
})
|
||||
|
||||
it('registers the models nav entry for declarations before or after apply', async () => {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The settings domain's base layer, with two roles and no presentation of its own. It provides `ctx.settingsScope`, the Host transport every preference row binds its durable namespace section through, and it declares the settings slot types registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), `settings.plugins.tab` (feature-owned pages inside the Plugins section), and `settings.onboarding` (ordered feature-owned pages). It depends on no `ui-*` presentation package, so any feature that owns a preference can reach it; the settings SHELL — the `sidebar.settings` occupant, its navigation, and the chrome — lives in ui-settings-general, because a shell dependency on ui-sidebar would close a reference graph cycle through ui-layout and ui-theme. The shell's own contract types live beside the shell for the same reason.
|
||||
The settings domain's base layer, with no presentation of its own. It provides `ctx.settingsScope`, the Host transport every preference row binds its durable namespace section through; `ctx.settingsSchema`, the synchronous schema-rehydration, validation, and immutable path-editing service used by settings plugins; and the settings slot types registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), `settings.plugins.tab` (feature-owned pages inside the Plugins section), and `settings.onboarding` (ordered feature-owned pages). It depends on no `ui-*` presentation package, so any feature that owns a preference can reach it; the settings SHELL — the `sidebar.settings` occupant, its navigation, and the chrome — lives in ui-settings-general, because a shell dependency on ui-sidebar would close a reference graph cycle through ui-layout and ui-theme. The shell's own contract types live beside the shell for the same reason.
|
||||
|
||||
The plugin injects nothing and waits for nothing: `ctx.settingsScope.bind(spec)` resolves the wire face through the CALLER's context at call time, so the bound scope's disposer belongs to the calling fiber, and the caller injects `connection` for the transport and `remote` for the invalidation. Listeners exist before the first background read starts, so a row's activation never blocks on the settings transport. A bound scope reloads on the forwarded `settings/document-updated` event for its own namespace and on `connection/reset`. Writes carry one field path and the last known namespace revision as `expectedRevision`; a rejected or failed write re-reads unless a newer write already superseded it, and a stale read never publishes over a newer one. Without a `decode` in the spec, a section that is not a plain object, fails its rehydrated schema, or carries a schema envelope this client cannot rehydrate publishes no value at all, so a row renders its own absent state instead of a half-decoded one.
|
||||
The plugin injects nothing and waits for nothing: schema operations are synchronous, while `ctx.settingsScope.bind(spec)` resolves the wire face through the caller's context at call time. The bound scope's disposer belongs to the calling fiber, and the caller injects `connection` for the transport and `remote` for invalidation. Listeners exist before the first background read starts, so a row's activation never blocks on the settings transport. A bound scope reloads on the forwarded `settings/document-updated` event for its own namespace and on `connection/reset`. Writes carry one field path and the last known namespace revision as `expectedRevision`; a rejected or failed write re-reads unless a newer write already superseded it, and a stale read never publishes over a newer one. Without a `decode` in the spec, a section that is not a plain object, fails its rehydrated schema, or carries a schema envelope this client cannot rehydrate publishes no value at all, so a row renders its own absent state instead of a half-decoded one.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
设置领域的底座,承担两项职责,本身不含任何呈现内容。它提供 `ctx.settingsScope`——每个偏好设置行绑定自己那份持久化命名空间分区所用的宿主传输层;并声明由注册方填充的设置 slot 类型:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)、`settings.plugins.tab`(“插件”分区内由各功能持有的页面)和 `settings.onboarding`(由各功能持有的有序页面)。它不依赖任何 `ui-*` 呈现包,因此任何持有偏好设置的功能都能够到它;设置**外壳**——`sidebar.settings` 占位方、它的导航与界面框架——位于 ui-settings-general,因为外壳一旦依赖 ui-sidebar,就会经 ui-layout 与 ui-theme 闭合出一条引用图环路。外壳自身的契约类型出于同一原因与外壳放在一起。
|
||||
设置领域的底座,本身不含任何呈现内容。它提供 `ctx.settingsScope`——每个偏好设置行绑定自己那份持久化命名空间分区所用的宿主传输层;`ctx.settingsSchema`——设置插件使用的同步 schema 重建、校验与不可变路径编辑服务;并声明由注册方填充的设置 slot 类型:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)、`settings.plugins.tab`(“插件”分区内由各功能持有的页面)和 `settings.onboarding`(由各功能持有的有序页面)。它不依赖任何 `ui-*` 呈现包,因此任何持有偏好设置的功能都能够到它;设置**外壳**——`sidebar.settings` 占位方、它的导航与界面框架——位于 ui-settings-general,因为外壳一旦依赖 ui-sidebar,就会经 ui-layout 与 ui-theme 闭合出一条引用图环路。外壳自身的契约类型出于同一原因与外壳放在一起。
|
||||
|
||||
该插件不注入任何服务、也不等待任何服务:`ctx.settingsScope.bind(spec)` 在调用时经**调用方**的 context 解析线路面,因此绑定所得 scope 的 disposer 归调用方 fiber 所有,而由调用方注入 `connection` 取得传输层、注入 `remote` 取得失效通知。监听器在首次后台读取启动之前就已存在,因此某一行的激活绝不会阻塞在设置传输层上。已绑定的 scope 会在收到属于自己命名空间的转发 `settings/document-updated` 事件时、以及在 `connection/reset` 时重新读取。写入携带单一字段路径以及最近已知的命名空间 revision 作为 `expectedRevision`;被拒绝或失败的写入会重新读取,除非已有更新的写入取代了它,而过期的读取绝不会覆盖发布更新的结果。若 spec 未提供 `decode`,则分区不是普通对象、未通过其重建后的 schema 校验、或携带本客户端无法重建的 schema 信封时,一律不发布任何值,于是行渲染自己的缺失状态,而不是一份半解码的值。
|
||||
该插件不注入任何服务、也不等待任何服务:schema 操作为同步调用,而 `ctx.settingsScope.bind(spec)` 在调用时经调用方的 context 解析线路面。绑定所得 scope 的 disposer 归调用方 fiber 所有,而由调用方注入 `connection` 取得传输层、注入 `remote` 取得失效通知。监听器在首次后台读取启动之前就已存在,因此某一行的激活绝不会阻塞在设置传输层上。已绑定的 scope 会在收到属于自己命名空间的转发 `settings/document-updated` 事件时、以及在 `connection/reset` 时重新读取。写入携带单一字段路径以及最近已知的命名空间 revision 作为 `expectedRevision`;被拒绝或失败的写入会重新读取,除非已有更新的写入取代了它,而过期的读取绝不会覆盖发布更新的结果。若 spec 未提供 `decode`,则分区不是普通对象、未通过其重建后的 schema 校验、或携带本客户端无法重建的 schema 信封时,一律不发布任何值,于是行渲染自己的缺失状态,而不是一份半解码的值。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -42,12 +42,21 @@ export class SettingsSchemaService extends Service {
|
||||
super(ctx, 'settingsSchema')
|
||||
}
|
||||
|
||||
/** Rehydrate one serialized `schema.toJSON()` envelope. */
|
||||
/**
|
||||
* Rehydrate one serialized `schema.toJSON()` envelope.
|
||||
* @param serialized - serialized Schemastery node.
|
||||
* @returns live schema node.
|
||||
*/
|
||||
rehydrate(serialized: unknown): SchemaNode {
|
||||
return new Schema(serialized as Schema)
|
||||
}
|
||||
|
||||
/** Return a validation failure message, or `undefined` for a valid draft. */
|
||||
/**
|
||||
* Validate a settings draft.
|
||||
* @param schema - live schema node.
|
||||
* @param draft - candidate settings value.
|
||||
* @returns validation failure text, or `undefined` when valid.
|
||||
*/
|
||||
validate(schema: SchemaNode, draft: unknown): string | undefined {
|
||||
try {
|
||||
;(schema as unknown as (value: unknown) => unknown)(draft)
|
||||
@@ -57,7 +66,12 @@ export class SettingsSchemaService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve an object, dict, or array schema node at a settings path. */
|
||||
/**
|
||||
* Resolve an object, dict, or array schema node at a settings path.
|
||||
* @param root - schema node to traverse.
|
||||
* @param path - object keys or array indexes.
|
||||
* @returns the resolved node, or `undefined` when the path is absent.
|
||||
*/
|
||||
nodeAtPath(root: SchemaNode, path: readonly string[]): SchemaNode | undefined {
|
||||
let node: SchemaNode | undefined = root
|
||||
for (const key of path) {
|
||||
@@ -69,7 +83,12 @@ export class SettingsSchemaService extends Service {
|
||||
return node
|
||||
}
|
||||
|
||||
/** Read a nested value by a string-key or array-index path. */
|
||||
/**
|
||||
* Read a nested value by a string-key or array-index path.
|
||||
* @param value - value to traverse.
|
||||
* @param path - object keys or array indexes.
|
||||
* @returns the resolved value, or `undefined` when the path is absent.
|
||||
*/
|
||||
getPath(value: unknown, path: readonly string[]): unknown {
|
||||
let current: unknown = value
|
||||
for (const key of path) {
|
||||
@@ -83,7 +102,12 @@ export class SettingsSchemaService extends Service {
|
||||
return current
|
||||
}
|
||||
|
||||
/** Report whether the final path key exists independently of its value. */
|
||||
/**
|
||||
* Report whether the final path key exists independently of its value.
|
||||
* @param value - value to traverse.
|
||||
* @param path - object keys or array indexes.
|
||||
* @returns whether the path exists.
|
||||
*/
|
||||
hasPath(value: unknown, path: readonly string[]): boolean {
|
||||
if (path.length === 0) return value !== undefined
|
||||
const parent = this.getPath(value, path.slice(0, -1))
|
||||
@@ -93,7 +117,14 @@ export class SettingsSchemaService extends Service {
|
||||
return key in parent
|
||||
}
|
||||
|
||||
/** Immutably set a nested value, materializing missing containers. */
|
||||
/**
|
||||
* Immutably set a nested value, materializing missing containers.
|
||||
* @param root - settings object to copy.
|
||||
* @param path - non-empty object-key or array-index path.
|
||||
* @param value - replacement value.
|
||||
* @returns copied root containing the replacement.
|
||||
* @throws when `path` is empty.
|
||||
*/
|
||||
setPath(root: Record<string, unknown>, path: readonly string[], value: unknown): Record<string, unknown> {
|
||||
if (path.length === 0) throw new Error('ui-settings: setPath needs a non-empty path')
|
||||
const { result, parent, leaf } = cloneSpine(root, path)
|
||||
@@ -102,7 +133,13 @@ export class SettingsSchemaService extends Service {
|
||||
return result
|
||||
}
|
||||
|
||||
/** Immutably remove a nested key, preserving an unchanged missing root. */
|
||||
/**
|
||||
* Immutably remove a nested key, preserving an unchanged missing root.
|
||||
* @param root - settings object to copy.
|
||||
* @param path - non-empty object-key or array-index path.
|
||||
* @returns copied root without the key, or `root` when the path is absent.
|
||||
* @throws when `path` is empty.
|
||||
*/
|
||||
deletePath(root: Record<string, unknown>, path: readonly string[]): Record<string, unknown> {
|
||||
if (path.length === 0) throw new Error('ui-settings: deletePath needs a non-empty path')
|
||||
if (!this.hasPath(root, path)) return root
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { apply, inject, SettingsScopeBinder } from '../src/client/index.ts'
|
||||
import { apply, inject, SettingsSchemaService, SettingsScopeBinder } from '../src/client/index.ts'
|
||||
|
||||
/** Boot the browser half over a bare root context; it injects nothing. */
|
||||
function bench() {
|
||||
@@ -18,6 +18,7 @@ describe('settings domain base plugin', () => {
|
||||
const { ctx, fiber } = bench()
|
||||
await fiber.await()
|
||||
expect(ctx.get('settingsScope')).toBeInstanceOf(SettingsScopeBinder)
|
||||
expect(ctx.get('settingsSchema')).toBeInstanceOf(SettingsSchemaService)
|
||||
})
|
||||
|
||||
it('fiber disposal retires the service', async () => {
|
||||
@@ -25,5 +26,6 @@ describe('settings domain base plugin', () => {
|
||||
await fiber.await()
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('settingsScope')).toBeUndefined()
|
||||
expect(ctx.get('settingsSchema')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Schema from '@deepseek-ai/schemastery'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SchemaNode } from '../src/client/schema.ts'
|
||||
import { SettingsSchemaService } from '../src/client/schema.ts'
|
||||
|
||||
const service = new SettingsSchemaService(new Context())
|
||||
const wire = (schema: Schema): unknown => JSON.parse(JSON.stringify(schema.toJSON()))
|
||||
|
||||
describe('SettingsSchemaService validation', () => {
|
||||
it('rehydrates a serialized envelope into a working validator', () => {
|
||||
const root = service.rehydrate(wire(Schema.object({ name: Schema.string().required() })))
|
||||
expect(service.validate(root, { name: 'ok' })).toBeUndefined()
|
||||
expect(service.validate(root, { name: 42 })).toContain('name')
|
||||
})
|
||||
|
||||
it('stringifies non-Error validation throws', () => {
|
||||
const hostile = (() => {
|
||||
throw 'plain-string failure'
|
||||
}) as unknown as SchemaNode
|
||||
expect(service.validate(hostile, {})).toBe('plain-string failure')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SettingsSchemaService path operations', () => {
|
||||
const root = { providers: { openai: { baseURL: 'https://x' } }, models: [{ id: 'a' }] }
|
||||
|
||||
it('reads nested object and array paths', () => {
|
||||
expect(service.getPath(root, [])).toBe(root)
|
||||
expect(service.getPath(root, ['providers', 'openai', 'baseURL'])).toBe('https://x')
|
||||
expect(service.getPath(root, ['models', '0', 'id'])).toBe('a')
|
||||
expect(service.getPath(root, ['providers', 'missing', 'x'])).toBeUndefined()
|
||||
expect(service.getPath(root, ['providers', 'openai', 'baseURL', 'deep'])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports presence by key existence rather than value truthiness', () => {
|
||||
expect(service.hasPath({ flag: false }, ['flag'])).toBe(true)
|
||||
expect(service.hasPath({ nested: { key: undefined } }, ['nested', 'key'])).toBe(true)
|
||||
expect(service.hasPath({}, ['missing'])).toBe(false)
|
||||
expect(service.hasPath({ leaf: 'x' }, ['leaf', 'deeper'])).toBe(false)
|
||||
expect(service.hasPath({ models: ['a'] }, ['models', '0'])).toBe(true)
|
||||
expect(service.hasPath({ models: ['a'] }, ['models', '1'])).toBe(false)
|
||||
expect(service.hasPath({ root: true }, [])).toBe(true)
|
||||
expect(service.hasPath(undefined, [])).toBe(false)
|
||||
})
|
||||
|
||||
it('sets nested paths immutably and materializes missing containers', () => {
|
||||
const draft = {}
|
||||
const next = service.setPath(draft, ['providers', 'openai', 'baseURL'], 'https://y')
|
||||
expect(draft).toEqual({})
|
||||
expect(next).toEqual({ providers: { openai: { baseURL: 'https://y' } } })
|
||||
const withArray = service.setPath(next, ['models', '0'], { id: 'a' })
|
||||
expect(withArray).toEqual({ providers: { openai: { baseURL: 'https://y' } }, models: [{ id: 'a' }] })
|
||||
const replaced = service.setPath(withArray, ['models', '0', 'id'], 'b')
|
||||
expect(replaced.models).toEqual([{ id: 'b' }])
|
||||
expect((withArray as { models: unknown[] }).models).toEqual([{ id: 'a' }])
|
||||
expect(() => service.setPath({}, [], 'x')).toThrow(/non-empty path/)
|
||||
})
|
||||
|
||||
it('deletes nested paths immutably and splices array indexes', () => {
|
||||
const draft = { providers: { openai: { baseURL: 'https://x', apiKey: 'k' } }, models: ['a', 'b'] }
|
||||
const withoutKey = service.deletePath(draft, ['providers', 'openai', 'apiKey'])
|
||||
expect(withoutKey).toEqual({ providers: { openai: { baseURL: 'https://x' } }, models: ['a', 'b'] })
|
||||
expect(draft.providers.openai.apiKey).toBe('k')
|
||||
const withoutModel = service.deletePath(withoutKey, ['models', '0'])
|
||||
expect(withoutModel.models).toEqual(['b'])
|
||||
expect(service.deletePath(draft, ['providers', 'missing', 'x'])).toBe(draft)
|
||||
expect(() => service.deletePath({}, [])).toThrow(/non-empty path/)
|
||||
})
|
||||
|
||||
it('deletes keys through array intermediates immutably', () => {
|
||||
const draft = { models: [{ id: 'a', contextWindow: 1 }] }
|
||||
const next = service.deletePath(draft, ['models', '0', 'contextWindow'])
|
||||
expect(next).toEqual({ models: [{ id: 'a' }] })
|
||||
expect(draft.models[0]).toEqual({ id: 'a', contextWindow: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('SettingsSchemaService node traversal', () => {
|
||||
const rootSchema = Schema.object({
|
||||
providers: Schema.dict(Schema.object({ baseURL: Schema.string() })),
|
||||
models: Schema.array(Schema.object({ id: Schema.string() })),
|
||||
leaf: Schema.string(),
|
||||
})
|
||||
|
||||
it('resolves object, dict, and array positions', () => {
|
||||
const root = service.rehydrate(wire(rootSchema))
|
||||
expect(service.nodeAtPath(root, [])).toBe(root)
|
||||
expect(service.nodeAtPath(root, ['providers', 'openai'])?.type).toBe('object')
|
||||
expect(service.nodeAtPath(root, ['providers', 'openai', 'baseURL'])?.type).toBe('string')
|
||||
expect(service.nodeAtPath(root, ['models', '0', 'id'])?.type).toBe('string')
|
||||
expect(service.nodeAtPath(root, ['missing'])).toBeUndefined()
|
||||
expect(service.nodeAtPath(root, ['missing', 'deeper'])).toBeUndefined()
|
||||
expect(service.nodeAtPath(root, ['leaf', 'below'])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('tolerates structural nodes missing their relation maps', () => {
|
||||
expect(service.nodeAtPath({ type: 'object' } as SchemaNode, ['x'])).toBeUndefined()
|
||||
expect(service.nodeAtPath({ type: 'dict' } as SchemaNode, ['x'])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { IApiClient, RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SettingsSchemaService } from '../src/client/schema.ts'
|
||||
import { SettingsScopeController, SettingsScopeBinder } from '../src/client/settings-scope.ts'
|
||||
import {
|
||||
SettingsScopeBinder, SettingsScopeController as ProductionSettingsScopeController,
|
||||
} from '../src/client/settings-scope.ts'
|
||||
|
||||
interface UiTestSettings {
|
||||
preference: 'light' | 'dark' | 'system'
|
||||
@@ -15,6 +17,17 @@ const ENVELOPE = z.object({
|
||||
preference: z.union(['light', 'dark', 'system']).default('system'),
|
||||
}).toJSON()
|
||||
|
||||
const settingsSchema = new SettingsSchemaService(new Context())
|
||||
const SettingsScopeController = class<T> extends ProductionSettingsScopeController<T> {
|
||||
constructor(
|
||||
api: Pick<IApiClient, 'settings'>,
|
||||
spec: SettingsScopeSpec<T>,
|
||||
persistence: 'host' | 'memory' = 'host',
|
||||
) {
|
||||
super(api, spec, persistence, settingsSchema)
|
||||
}
|
||||
}
|
||||
|
||||
let rpc = 0
|
||||
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
@@ -112,6 +125,16 @@ describe('SettingsScopeController', () => {
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
|
||||
})
|
||||
|
||||
it('rejects host values when no schema service is available', async () => {
|
||||
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 2))
|
||||
const scope = new ProductionSettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
|
||||
})
|
||||
|
||||
it('suppresses a superseded read of an unexposed namespace', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
|
||||
|
||||
@@ -6,7 +6,7 @@ Theme plugin: ThemeRuntime over the --dsw-* token base stylesheets (static scale
|
||||
|
||||
When the host composition includes an HTTP server, the host half injects a synchronous bootstrap immediately after the opening `<body>` tag. Each index response embeds the registered Host setting for `ui-theme.preference`, or `system` when no settings provider is present; the browser resolves `system` from the OS scheme, then sets `color-scheme` and `body[data-ds-dark-theme]` before the shell loading page renders. Compositions without an HTTP server remain unaffected, and ThemeRuntime and ui-layout remain authoritative for client state and subsequent DOM updates after the plugin tree activates.
|
||||
|
||||
`src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them.
|
||||
`src/styles/` holds five sheets imported in order by ui-theme's dynamic client entry: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme instead of leaving theme CSS in the static web shell. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them.
|
||||
|
||||
Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both rendering paths read that pair. An elevated surface (menu, popover, dialog) sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container; one rebind retints whichever path the engine took. The pair's other legal target is `transparent`, which draws no thumb at all — [ui-sidebar](../ui-sidebar/README.md) rebinds its column that way while the pointer is elsewhere. A rebind to the l1 pair is not a rebind; it restates the base-surface default. `--dsh-scrollbar-width` mirrors the WebKit bar's layout width for surfaces that align themselves beside a space-consuming bar — [ui-conversation](../ui-conversation/README.md) reads it for the overlay composer seat's `right` offset — and the scrollbar-styles spec pairs it with the mirrored rule and the consumer.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
当主机组合包含 HTTP 服务器时,主机侧紧接 `<body>` 起始标签注入同步引导代码。每份 index 响应会嵌入已注册的 Host 设置 `ui-theme.preference`,没有 settings provider 时则嵌入 `system`;浏览器按操作系统配色解析 `system`,随后在外壳加载页面渲染前设置 `color-scheme` 和 `body[data-ds-dark-theme]`。不含 HTTP 服务器的组合不受影响,插件树激活后,ThemeRuntime 与 ui-layout 仍分别是客户端状态和后续 DOM 更新的权威来源。
|
||||
|
||||
`src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。
|
||||
`src/styles/` 下有五张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR 会随 ui-theme 一同移除这些样式,而不会把主题 CSS 留在静态 Web 外壳中。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。
|
||||
|
||||
滚动条重新绑定约定:`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1(基础表面)token,两条渲染路径都读取这一组变量。高层级表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。这组变量的另一个合法目标是 `transparent`,即完全不绘制滑块——[ui-sidebar](../ui-sidebar/README.md) 在指针不在栏内时就这样重新绑定自己的列。绑回 l1 那组不算重新绑定,它只是重述基础表面的默认值。`--dsh-scrollbar-width` 镜像 WebKit 滚动条的布局宽度,供需要与占布局宽度的滚动条对齐的表面使用——[ui-conversation](../ui-conversation/README.md) 用它作为覆盖 composer 座位 `right` 偏移——scrollbar-styles 规格把它与镜像规则及消费者配对检查。
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./styles/*": "./lib/styles/*",
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
@@ -75,7 +74,6 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/styles",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { AppearanceRowInjected } from './AppearanceRow.tsx'
|
||||
import { AppearanceRow } from './AppearanceRow.tsx'
|
||||
import { createAppearanceRowStore } from './settings-store.ts'
|
||||
import { installThemeStyles } from './styles.ts'
|
||||
import { en, zh, type ThemeKey } from './locales.ts'
|
||||
import {
|
||||
DEFAULT_PREFERENCE, isThemePreference, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE,
|
||||
@@ -382,6 +383,7 @@ export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
installThemeStyles(ctx)
|
||||
const host = ctx.settingsScope.bind<ThemeSettings>({ namespace: THEME_SETTINGS_NAMESPACE })
|
||||
const theme = new ThemeRuntime(ctx, host)
|
||||
ctx.provide('theme', theme)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import base from '../styles/base.css?inline'
|
||||
import designPlatform from '../styles/design-platform.css?inline'
|
||||
import scrollbar from '../styles/scrollbar.css?inline'
|
||||
import gradientShadowText from '../styles/gradient-shadow-text.css?inline'
|
||||
import shiki from '../styles/shiki.css?inline'
|
||||
|
||||
const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-theme'
|
||||
|
||||
const STYLES = [
|
||||
['base.css', base],
|
||||
['design-platform.css', designPlatform],
|
||||
['scrollbar.css', scrollbar],
|
||||
['gradient-shadow-text.css', gradientShadowText],
|
||||
['shiki.css', shiki],
|
||||
] as const
|
||||
|
||||
/** Mount the global theme sheets for exactly the owning plugin lifetime. */
|
||||
export function installThemeStyles(ctx: Context): void {
|
||||
if (typeof document === 'undefined') return
|
||||
for (const [name, css] of STYLES) {
|
||||
ctx.effect(() => {
|
||||
const tag = document.createElement('style')
|
||||
tag.dataset.plugin = PLUGIN_ID
|
||||
tag.dataset.pluginCss = `${PLUGIN_ID}/${name}`
|
||||
tag.textContent = css
|
||||
document.head.appendChild(tag)
|
||||
return () => { tag.remove() }
|
||||
}, `ui-theme: ${name} stylesheet`)
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,8 @@ declare module '*.module.css' {
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
|
||||
declare module '*.css?inline' {
|
||||
const css: string
|
||||
export default css
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Dynamic ui-theme entry owns the global styles in dependency order. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { installThemeStyles } from '../src/client/styles.ts'
|
||||
|
||||
const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-theme'
|
||||
|
||||
afterEach(() => {
|
||||
document.head.querySelectorAll(`style[data-plugin="${PLUGIN_ID}"]`).forEach((node) => { node.remove() })
|
||||
})
|
||||
|
||||
describe('ui-theme client styles', () => {
|
||||
it('mounts every global sheet in dependency order and removes them on dispose', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin({
|
||||
apply(scope) { installThemeStyles(scope) },
|
||||
})
|
||||
await fiber.await()
|
||||
|
||||
const styles = [...document.head.querySelectorAll<HTMLStyleElement>(`style[data-plugin="${PLUGIN_ID}"]`)]
|
||||
expect(styles.map(style => style.dataset.pluginCss)).toEqual([
|
||||
`${PLUGIN_ID}/base.css`,
|
||||
`${PLUGIN_ID}/design-platform.css`,
|
||||
`${PLUGIN_ID}/scrollbar.css`,
|
||||
`${PLUGIN_ID}/gradient-shadow-text.css`,
|
||||
`${PLUGIN_ID}/shiki.css`,
|
||||
])
|
||||
await fiber.dispose()
|
||||
expect(document.head.querySelectorAll(`style[data-plugin="${PLUGIN_ID}"]`)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -3,9 +3,4 @@ import { clientBundle } from '../tsdown.client.ts'
|
||||
export default clientBundle(
|
||||
'@deepseek-ai/dsh-client-ui-theme',
|
||||
['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
{
|
||||
lib: {
|
||||
copy: [{ from: 'src/styles/*', to: 'lib/styles' }],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -293,7 +293,7 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi
|
||||
openFile: () => {},
|
||||
inspectCall: () => {},
|
||||
forkAt: () => {},
|
||||
loadImage: () => Promise.reject(new Error('unused')),
|
||||
renderMessageImages: () => null,
|
||||
fileMentions: () => undefined,
|
||||
openSession,
|
||||
t: makeTranslate(zh),
|
||||
|
||||
@@ -2,16 +2,14 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Web shell kernel: `new AppWebEntry(el, seams?).run()` mounts the whole client through the two-stage boot (web2). Stage one (module face): build the client module system (`@deepseek-ai/dsh-client-modules`) over the host-pushed entry graph (`window.__DSH_BOOT__`) and prefetch the `immediately` tier in parallel — bundle execution registers factories only. Stage two (plugin face): mount the vendored cordis Loader with the module system injected through its `internal` contract, create one loader entry per graph row plus the shell-own app-shell assembly entry (tree.import materializes each module), and gate AppRoot on the settle (loader quiesced + every entry fiber ACTIVE → full UI in one switch). Composition is entirely the host graph's: the roster and the immediately tier live in the composing app; the shell makes zero composition decisions.
|
||||
Web boot kernel: `new AppWebEntry(el, seams?).run()` mounts the client through two stages. The module stage builds `@deepseek-ai/dsh-client-modules` over the host-provided `window.__DSH_BOOT__` graph and prefetches the `immediately` tier. The plugin stage mounts the vendored Cordis Loader, injects that module system through the Loader's `internal` interface, creates every graph entry, and waits for every fiber to become ACTIVE. It then calls the dynamic render service's `ctx.appShell.mount(el)` operation, replacing the boot page with the complete UI. The host graph owns the roster and prefetch marks; this package adds only the statically adopted modules bootstrap entry.
|
||||
|
||||
Shell self-sufficiency (web2 hard rule): the kernel value-imports no plugin package — the boot status store and signals are hand-rolled here (`loader-status.ts`), so the loading page works while (and especially when) plugins fail. The app-shell assembly (`@deepseek-ai/dsh-client-app-shell`, a shell-owned pseudo entry with no npm package behind it) is the only module registered through `registerStatic`; it inject-waits on slots/sessions/layout like any plugin.
|
||||
The boot page uses plain DOM and local CSS, so client-bundle and plugin-activation failures remain visible. React mounting, the slot renderer, application assembly, and browser-title projection live in [`render-service`](../render-service/README.md). The modules package is the only plugin package registered through `registerStatic`, because the module system cannot load itself.
|
||||
|
||||
`PLATFORM_MODULES` (src/platform.ts) is the single source of truth for shared modules: seed-table keys, tsdown client externals, and the Vite alias set are its projections.
|
||||
|
||||
The optional override parameter `seams` forwards the module system's `loadBundle` transport override (`BootSeams`) for environments where external `<script>` execution cannot reach the page context; ordinary browser callers omit it.
|
||||
|
||||
The shell owns browser-title projection. With a selected session carrying a durable title, it renders `<session title> — <existing HTML title>` and reacts to later title revisions; no selection or a selected untitled session preserves the existing title, and shell unmount restores it. The existing HTML title remains the configurable product suffix.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the entry shell boots the browser plugin tree; nothing here reaches a model request.
|
||||
@@ -22,5 +20,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One-shot rendering by design** — the UI waits for the boot settle; a single entry failure keeps the loading page with a loud per-entry report, no partial availability (progressive rendering returns with its own project).
|
||||
- **Narrow-window shell behavior lacks an assembled walkthrough** — ui-layout implements the concession chain, but this package has no shell-level narrow-viewport acceptance case.
|
||||
- **The application waits for the full roster** — one failed entry keeps the framework-free boot page visible with a per-entry report; partial UI availability is not supported.
|
||||
|
||||
@@ -2,16 +2,14 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Web 外壳内核:`new AppWebEntry(el, seams?).run()` 通过两阶段启动(web2)挂载整个客户端。第一阶段(模块侧):构建客户端模块系统(`@deepseek-ai/dsh-client-modules`),以主机推送的配置项图(`window.__DSH_BOOT__`)为基础,并行预取 `immediately` 层级;执行组合包只会注册 factory。第二阶段(插件侧):挂载仓库内置的 Cordis Loader,并通过其 `internal` 约定注入模块系统;为每一行图数据创建一个 loader 配置项,另创建外壳自身的 app-shell 组装配置项(tree.import 会物化各模块);以 settle 作为 AppRoot 的门禁(loader 完全停稳 + 每个配置项 fiber 都为 ACTIVE → 一次切换显示完整 UI)。组合完全由主机图决定:花名册和 immediately 层级都位于负责组合的应用中;外壳不作任何组合决策。
|
||||
Web 启动内核:`new AppWebEntry(el, seams?).run()` 分两个阶段挂载客户端。模块阶段基于宿主提供的 `window.__DSH_BOOT__` 图构建 `@deepseek-ai/dsh-client-modules`,并预取 `immediately` 层级。插件阶段挂载仓库内置的 Cordis Loader,通过 Loader 的 `internal` 接口注入该模块系统,创建全部图 entry,并等待每个 fiber 进入 ACTIVE。随后它调用动态渲染服务的 `ctx.appShell.mount(el)` 操作,以完整 UI 替换启动页。名册与预取标记归宿主图所有;本包只额外加入静态接纳的 modules 启动 entry。
|
||||
|
||||
外壳自给自足(web2 硬性规则):内核不对任何插件包执行值导入;启动状态 store 与信号在这里手写(`loader-status.ts`),因此即使插件失败,加载页面仍能工作,而此时这一点尤其重要。app-shell 组装(`@deepseek-ai/dsh-client-app-shell`,由外壳拥有、背后没有 npm 包的伪配置项)是唯一通过 `registerStatic` 注册的模块;它与任何插件一样,通过 inject 等待 slots/sessions/layout。
|
||||
启动页只使用原生 DOM 与本地 CSS,因此客户端 bundle 或插件激活失败时仍能显示。React 挂载、slot 渲染器、应用组装和浏览器标题投影位于 [`render-service`](../render-service/README.md)。modules 包是唯一通过 `registerStatic` 注册的插件包,因为模块系统无法加载自身。
|
||||
|
||||
`PLATFORM_MODULES`(src/platform.ts)是共享模块接口的唯一真源:种子表 key、tsdown 客户端 external 和 vite alias 集都是它的投影。
|
||||
|
||||
可选的覆盖参数 `seams` 会为外部 `<script>` 执行无法到达页面上下文的环境转发模块系统的 `loadBundle` 传输覆盖(`BootSeams`);普通浏览器调用方省略此参数。
|
||||
|
||||
外壳拥有浏览器标题投影。选中带有持久标题的会话时,它会渲染 `<session title> — <existing HTML title>` 并响应后续标题修订;未选择会话或选中无标题会话时,会保留现有标题;外壳卸载时恢复标题。现有 HTML 标题仍是可配置的产品后缀。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。入口外壳负责启动浏览器插件树;这里没有任何内容进入模型请求。
|
||||
@@ -22,5 +20,4 @@ Web 外壳内核:`new AppWebEntry(el, seams?).run()` 通过两阶段启动(w
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **有意采用一次性渲染**:UI 等待启动 settle;只要一个配置项失败,加载页面就会保留并逐项显示醒目的报告,不提供部分可用性(渐进式渲染将作为独立项目恢复)。
|
||||
- **窄窗口外壳行为缺少组装后演练**:ui-layout 已实现让步链,但该包没有外壳级窄视口验收用例。
|
||||
- **应用会等待完整名册**:只要一个 entry 失败,不依赖框架的启动页就会保留并逐项报告;不支持部分 UI 可用。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-web",
|
||||
"description": "Web shell kernel: bootWebShell (module system holding + seed table + two-stage boot + AppRoot gate + app-shell assembly entry), consumed by the apps/web vite entry",
|
||||
"description": "Web boot kernel: static module table, Cordis loader, framework-free boot page, and render-service handoff",
|
||||
"version": "0.1.0-rc.7",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -28,19 +28,15 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-render-service": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/* Boot page styles are self-contained: the theme base stylesheets are linked
|
||||
by the shell, but the loading page must render acceptably even before/without
|
||||
them, so this file uses tokens with neutral fallbacks (the one sanctioned
|
||||
fallback site — plugin packages must not do this). */
|
||||
|
||||
.boot {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--dsw-alias-bg-base, #f9fafb);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--dsw-alias-label-primary, #0f1115);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary, #81858c);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--dsw-alias-border-l2, rgb(0 0 0 / 10%));
|
||||
border-top-color: var(--dsw-alias-brand-primary, #3964fe);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.failed {
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.failedTitle {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary, #0f1115);
|
||||
}
|
||||
|
||||
.failedItem {
|
||||
font-family: var(--ds-font-family-code, ui-monospace, 'SF Mono', Menlo, Consolas, 'Courier New');
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-secondary, #61666b);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* Shell root: boot loading page → (boot settled) → real UI in one switch.
|
||||
* Pure kernel component with zero plugin dependencies — before settled it may
|
||||
* only rely on itself (the fail-loud presentation must not depend on the
|
||||
* system whose failure it reports; the status/signal stores are kernel-own,
|
||||
* shell self-sufficiency rule); the real UI is produced by the
|
||||
* app-shell entry once every entry is active. A failed boot keeps the
|
||||
* loading page, lists the per-entry fiber states and the sweep report (fail
|
||||
* loud, no partial UI).
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { KernelSignal, LoaderStatus } from './loader-status.ts'
|
||||
import css from './AppRoot.module.css'
|
||||
|
||||
/** AppRoot props: settled signal, fiber-state projection feed, boot failure report, deferred real-UI factory. */
|
||||
export interface AppRootProps {
|
||||
/** True once the boot chain settled (loader quiesced + all entries ACTIVE); the boot closure flips it. */
|
||||
settled: KernelSignal<boolean>
|
||||
/** Per-entry fiber-state projection store (drives loading/failed rendering). */
|
||||
status: KernelSignal<LoaderStatus>
|
||||
/** Boot failure report (the settle rejection message); undefined while loading or after success. */
|
||||
error: KernelSignal<string | undefined>
|
||||
/** Builds the real UI; called only after settled. */
|
||||
renderApp: () => ReactNode
|
||||
}
|
||||
|
||||
/** Boot gate: loading page until the boot settles; failures stay here. */
|
||||
export function AppRoot(props: AppRootProps) {
|
||||
const settled = useSyncExternalStore(props.settled.subscribe, props.settled.getSnapshot)
|
||||
const status = useSyncExternalStore(props.status.subscribe, props.status.getSnapshot)
|
||||
const error = useSyncExternalStore(props.error.subscribe, props.error.getSnapshot)
|
||||
const failed = Object.entries(status).filter(([, s]) => s === 'failed')
|
||||
|
||||
if (settled) return <>{props.renderApp()}</>
|
||||
|
||||
const loud = error !== undefined || failed.length > 0
|
||||
|
||||
return (
|
||||
<div className={css.boot}>
|
||||
<div className={css.card}>
|
||||
<div className={css.wordmark}>HARNESS</div>
|
||||
{!loud
|
||||
? (
|
||||
<>
|
||||
<div className={css.spinner} />
|
||||
<div className={css.hint}>Loading plugins…</div>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<div className={css.failed}>
|
||||
<div className={css.failedTitle}>Failed to load plugins</div>
|
||||
{failed.map(([id]) => <div key={id} className={css.failedItem}>{id}</div>)}
|
||||
{error !== undefined && <div className={css.failedItem}>{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* App-shell assembly plugin. Its pseudo package id exists only in the host
|
||||
* graph and shell registry; there is no npm package behind it.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { buildRenderApp } from './app.tsx'
|
||||
|
||||
/** Shell-owned pseudo entry id under which the host graph mounts this plugin. */
|
||||
export const APP_SHELL_ID = '@deepseek-ai/dsh-client-app-shell'
|
||||
|
||||
/** The assembled-UI face AppRoot renders once the boot settles. */
|
||||
export interface AppShellService {
|
||||
/** Build (once) and render the real UI tree. */
|
||||
renderApp: () => ReactNode
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** The shell assembly face, provided by the app-shell entry once its inject set is active. */
|
||||
appShell: AppShellService
|
||||
}
|
||||
}
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'app-shell'
|
||||
|
||||
/** Services required before shell assembly. */
|
||||
export const inject = ['slots', 'sessions', 'layout']
|
||||
|
||||
/** Installs the React renderer and exposes the assembled application.
|
||||
* @param ctx - Plugin context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
// The renderer install is shell territory (web-react is shell-bundled),
|
||||
// but ctx.slots exists only once the runtime entry is active — so it lands
|
||||
// here, on the entry whose inject set guarantees that ordering.
|
||||
ctx.slots.install(createSlotRenderer())
|
||||
|
||||
// Assemble once on first render: the closure must be identity-stable
|
||||
// across AppRoot re-renders.
|
||||
let renderApp: (() => ReactNode) | undefined
|
||||
ctx.reflect.provide('appShell', {
|
||||
renderApp: (): ReactNode => {
|
||||
renderApp ??= buildRenderApp({ ctx })
|
||||
return renderApp()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
/* Shell-owned global base: full-height mount plus the theme token sheets.
|
||||
* The five ui-theme sheets are the sole token source (--dsw-*); the shell
|
||||
* links them here so tokens exist before any plugin CSS lands. scrollbar.css
|
||||
* follows design-platform.css because it reads that sheet's tokens. */
|
||||
@import '@deepseek-ai/dsh-client-ui-theme/styles/base.css';
|
||||
@import '@deepseek-ai/dsh-client-ui-theme/styles/design-platform.css';
|
||||
@import '@deepseek-ai/dsh-client-ui-theme/styles/scrollbar.css';
|
||||
@import '@deepseek-ai/dsh-client-ui-theme/styles/gradient-shadow-text.css';
|
||||
@import '@deepseek-ai/dsh-client-ui-theme/styles/shiki.css';
|
||||
/* Shell-owned mount defaults. Theme tokens arrive with the ui-theme client
|
||||
* plugin before the loader roster activates. */
|
||||
|
||||
html,
|
||||
body,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/* The framework-free boot page cannot depend on theme delivery succeeding. */
|
||||
|
||||
.boot {
|
||||
--dsh-boot-bg: #f9fafb;
|
||||
--dsh-boot-label-primary: #0f1115;
|
||||
--dsh-boot-label-secondary: #61666b;
|
||||
--dsh-boot-label-tertiary: #81858c;
|
||||
--dsh-boot-border: rgb(0 0 0 / 10%);
|
||||
--dsh-boot-brand: #3964fe;
|
||||
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--dsw-alias-bg-base, var(--dsh-boot-bg));
|
||||
}
|
||||
|
||||
:global(body[data-ds-dark-theme]) .boot {
|
||||
--dsh-boot-bg: #151517;
|
||||
--dsh-boot-label-primary: #f9fafb;
|
||||
--dsh-boot-label-secondary: #cfd3d6;
|
||||
--dsh-boot-label-tertiary: #adb2b8;
|
||||
--dsh-boot-border: rgb(255 255 255 / 12%);
|
||||
--dsh-boot-brand: #f9fafb;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--dsw-alias-label-primary, var(--dsh-boot-label-primary));
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary, var(--dsh-boot-label-tertiary));
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--dsw-alias-border-l2, var(--dsh-boot-border));
|
||||
border-top-color: var(--dsw-alias-brand-primary, var(--dsh-boot-brand));
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.failed {
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.failedTitle {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary, var(--dsh-boot-label-primary));
|
||||
}
|
||||
|
||||
.failedItem {
|
||||
font-family: var(--ds-font-family-code, ui-monospace, 'SF Mono', Menlo, Consolas, 'Courier New');
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-secondary, var(--dsh-boot-label-secondary));
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Framework-free boot page and failure report. It remains available when a
|
||||
* client plugin fails because React arrives only with the render service.
|
||||
* @module @deepseek-ai/dsh-client-web/src/boot-page
|
||||
*/
|
||||
import type { LoaderEntryState } from './loader-status.ts'
|
||||
import css from './boot-page.module.css'
|
||||
|
||||
/** Create a div with one module class and optional text. */
|
||||
function div(className: string | undefined, text?: string): HTMLDivElement {
|
||||
const el = document.createElement('div')
|
||||
el.className = className ?? ''
|
||||
if (text !== undefined) el.textContent = text
|
||||
return el
|
||||
}
|
||||
|
||||
/** Kernel-owned page mounted below the application's root element. */
|
||||
export class BootPage {
|
||||
private readonly root: HTMLDivElement
|
||||
private readonly card: HTMLDivElement
|
||||
private readonly states = new Map<string, LoaderEntryState>()
|
||||
private failure: string | undefined
|
||||
|
||||
/**
|
||||
* Build and attach the boot page.
|
||||
* @param container - Application mount point.
|
||||
*/
|
||||
constructor(container: HTMLElement) {
|
||||
this.root = div(css.boot)
|
||||
this.card = div(css.card)
|
||||
this.card.append(div(css.wordmark, 'HARNESS'))
|
||||
this.root.append(this.card)
|
||||
container.append(this.root)
|
||||
this.render()
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one loader entry's fiber state.
|
||||
* @param id - Loader entry name.
|
||||
* @param state - Projected fiber state.
|
||||
*/
|
||||
setState(id: string, state: LoaderEntryState): void {
|
||||
this.states.set(id, state)
|
||||
this.render()
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the boot failure report.
|
||||
* @param message - Failure report text.
|
||||
*/
|
||||
fail(message: string): void {
|
||||
this.failure = message
|
||||
this.render()
|
||||
}
|
||||
|
||||
/** Detach the page before or after the render service takes the mount point. */
|
||||
dispose(): void {
|
||||
this.root.remove()
|
||||
}
|
||||
|
||||
/** Redraw the state-dependent content below the wordmark. */
|
||||
private render(): void {
|
||||
while (this.card.childNodes.length > 1) this.card.lastChild?.remove()
|
||||
const failed = [...this.states].filter(([, state]) => state === 'failed').map(([id]) => id)
|
||||
if (this.failure === undefined && failed.length === 0) {
|
||||
this.card.append(div(css.spinner), div(css.hint, 'Loading plugins…'))
|
||||
return
|
||||
}
|
||||
const report = div(css.failed)
|
||||
report.append(div(css.failedTitle, 'Failed to load plugins'))
|
||||
for (const id of failed) report.append(div(css.failedItem, id))
|
||||
if (this.failure !== undefined) report.append(div(css.failedItem, this.failure))
|
||||
this.card.append(report)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Web boot kernel. It owns only the module system, Cordis loader, and a
|
||||
* framework-free boot page. The dynamic render service receives the mount
|
||||
* point after every client entry activates.
|
||||
* @module @deepseek-ai/dsh-client-web/src/boot
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import * as ModulesClient from '@deepseek-ai/dsh-client-modules/client'
|
||||
import {
|
||||
ClientModuleSystem, parseBootManifest,
|
||||
type BootManifest, type ClientModuleSystemOptions, type DshWindow,
|
||||
} from '@deepseek-ai/dsh-client-modules/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-render-service/client'
|
||||
import { BootPage } from './boot-page.ts'
|
||||
import { getStaticModules } from './seed.ts'
|
||||
import { STATE_LABELS } from './loader-status.ts'
|
||||
import './base.css'
|
||||
|
||||
/** Module transport hook replaced by jsdom tests. */
|
||||
export type BootSeams = Pick<ClientModuleSystemOptions, 'loadBundle'>
|
||||
|
||||
/** Statically adopted bootstrap package that constructs the client module system. */
|
||||
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
|
||||
|
||||
/** Browser boot entry consumed by `apps/web`. */
|
||||
export class AppWebEntry {
|
||||
private readonly container: HTMLElement
|
||||
private readonly seams: BootSeams | undefined
|
||||
private readonly page: BootPage
|
||||
private ctx: Context | undefined
|
||||
private modules!: ClientModuleSystem
|
||||
private manifest!: BootManifest
|
||||
|
||||
/**
|
||||
* Draw the boot page; {@link run} starts the loader.
|
||||
* @param container - Application mount point.
|
||||
* @param seams - Optional module transport replacement.
|
||||
*/
|
||||
constructor(container: HTMLElement, seams?: BootSeams) {
|
||||
this.container = container
|
||||
this.seams = seams
|
||||
this.page = new BootPage(container)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and activate every client entry, then hand the mount point to the
|
||||
* render service. Plugin failures remain visible on the boot page.
|
||||
* @returns Resolves after application mount or failure rendering.
|
||||
*/
|
||||
async run(): Promise<void> {
|
||||
this.manifest = parseBootManifest((globalThis as DshWindow).__DSH_BOOT__)
|
||||
this.modules = new ClientModuleSystem({
|
||||
modules: this.manifest.modules,
|
||||
staticModules: getStaticModules(),
|
||||
...this.seams,
|
||||
})
|
||||
this.modules.registerStatic(MODULES_ID, ModulesClient)
|
||||
;(globalThis as DshWindow).__DSH_MODULES__ = this.modules
|
||||
|
||||
const prefetching = this.prefetchImmediateTier()
|
||||
const ctx = new Context()
|
||||
this.ctx = ctx
|
||||
try {
|
||||
await this.runPluginBoot(ctx, prefetching)
|
||||
await this.mountApp(ctx)
|
||||
} catch (reason) {
|
||||
console.error(reason)
|
||||
this.page.fail(reason instanceof Error ? reason.message : String(reason))
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispose the client plugin tree and whichever page owns the mount point. */
|
||||
async dispose(): Promise<void> {
|
||||
const ctx = this.ctx
|
||||
this.ctx = undefined
|
||||
if (ctx !== undefined) await ctx.fiber.dispose()
|
||||
this.page.dispose()
|
||||
}
|
||||
|
||||
/** Mount through a dependency fiber so replacing appShell remounts the application. */
|
||||
private async mountApp(ctx: Context): Promise<void> {
|
||||
const mounted = ctx.inject(['appShell'], (scope) => {
|
||||
const shell = scope.get('appShell')
|
||||
if (shell === undefined) throw new Error('web boot: appShell service missing after settled')
|
||||
scope.effect(() => shell.mount(this.container), 'web boot: application mount')
|
||||
this.page.dispose()
|
||||
})
|
||||
await mounted
|
||||
}
|
||||
|
||||
/** Prefetch stage-one bundles; their import path owns any eventual failure. */
|
||||
private async prefetchImmediateTier(): Promise<void> {
|
||||
await Promise.all(this.manifest.plugins
|
||||
.filter(row => row.immediately)
|
||||
.map(row => this.modules.prefetch(row.id).catch((_prefetchError: unknown) => {
|
||||
// Prefetch only starts transport early; the Loader import retries and reports this bundle failure.
|
||||
})))
|
||||
}
|
||||
|
||||
/** Mount the Loader, create all graph entries, await quiescence, and audit activation. */
|
||||
private async runPluginBoot(ctx: Context, prefetching: Promise<void>): Promise<void> {
|
||||
await ctx.plugin(Loader)
|
||||
const loader = ctx.loader
|
||||
loader.internal = this.modules as never
|
||||
|
||||
ctx.on('internal/status', (fiber) => {
|
||||
const entry = fiber.entry
|
||||
if (entry === undefined || entry.fiber === undefined) return
|
||||
this.page.setState(entry.options.name, STATE_LABELS[entry.fiber.state])
|
||||
})
|
||||
|
||||
await prefetching
|
||||
const rows = [MODULES_ID, ...this.manifest.plugins.map(row => row.id).filter(id => id !== MODULES_ID)]
|
||||
await Promise.all(rows.map(async (name) => {
|
||||
this.page.setState(name, 'loading')
|
||||
const id = await loader.create({ name })
|
||||
if (loader.resolve(id).fiber === undefined) this.page.setState(name, 'failed')
|
||||
}))
|
||||
|
||||
await loader.await()
|
||||
this.assertEntriesActive(ctx)
|
||||
}
|
||||
|
||||
/** Reject entries that failed import/apply or still wait on missing services. */
|
||||
private assertEntriesActive(ctx: Context): void {
|
||||
const failures: string[] = []
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
const name = entry.options.name
|
||||
if (entry.fiber === undefined) {
|
||||
failures.push(`${name}: import failed (see console for the import error)`)
|
||||
continue
|
||||
}
|
||||
const state = STATE_LABELS[entry.fiber.state]
|
||||
if (state === 'active') continue
|
||||
if (state === 'pending') {
|
||||
const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
|
||||
failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
||||
} else {
|
||||
failures.push(`${name}: ${state}`)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
/**
|
||||
* Web shell boot kernel — the face consumed by the apps/web entry. Everything
|
||||
* here is machinery that cannot itself be a loader entry, and none of it
|
||||
* value-imports a plugin package (shell self-sufficiency rule: the
|
||||
* loading page must work while — especially when — plugins fail). The one
|
||||
* sanctioned exception is the modules package (bootstrap
|
||||
* identity): the module system cannot arrive through itself, so its class
|
||||
* and its client-half wrapper are shell-bundled and the kernel adopts its
|
||||
* plugin entry once cordis is up.
|
||||
*
|
||||
* AppWebEntry.run(), module face first, then plugin face: parse
|
||||
* `window.__DSH_BOOT__` into the two-view BootManifest (wire boundary)
|
||||
* → build the module system over the module-view rows → render the loading
|
||||
* page → prefetch every `immediately` row in parallel with mounting the
|
||||
* vendored cordis Loader (`internal` contract injection BEFORE any entry exists —
|
||||
* the bare-import fallback in tree.import must never run in a browser) →
|
||||
* await the prefetch tier, THEN adopt the modules entry and create one
|
||||
* loader entry per plugin-view row plus the shell-own app-shell assembly
|
||||
* entry → loader.await() + a full fiber sweep (all ACTIVE, else fail
|
||||
* listing who/what/which service) → flip the settled signal so AppRoot
|
||||
* switches to the real UI in one pass.
|
||||
*
|
||||
* Entry creation waits for the whole immediately tier: materialization runs
|
||||
* synchronous cross-package require edges (e.g. locale → runtime/client) that
|
||||
* fiber inject waiting cannot protect — a bundle's factory must be
|
||||
* registered before any dependent entry materializes. Per-row prefetch
|
||||
* failures still resolve silently (the create-side import reloads and
|
||||
* owns the loud failure), so the barrier never turns one bad bundle into a
|
||||
* boot-wide fail-fast.
|
||||
*
|
||||
* Composition lives in the host graph; the shell makes zero composition
|
||||
* decisions (the app-shell assembly is itself a graph entry, the only
|
||||
* shell-own module registered with the module system).
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import * as ModulesClient from '@deepseek-ai/dsh-client-modules/client'
|
||||
import {
|
||||
ClientModuleSystem, parseBootManifest,
|
||||
type BootManifest, type ClientModuleSystemOptions, type DshWindow,
|
||||
} from '@deepseek-ai/dsh-client-modules/client'
|
||||
import * as AppShell from './app-shell.ts'
|
||||
import { APP_SHELL_ID } from './app-shell.ts'
|
||||
import { AppRoot } from './AppRoot.tsx'
|
||||
import { getStaticModules } from './seed.ts'
|
||||
import { STATE_LABELS, createLoaderStatusStore, createSignal } from './loader-status.ts'
|
||||
import './base.css'
|
||||
|
||||
/** Module transport hook the shell passes through (jsdom tests replace the <script> path). */
|
||||
export type BootSeams = Pick<ClientModuleSystemOptions, 'loadBundle'>
|
||||
|
||||
/**
|
||||
* The modules package's own graph row id. The kernel adopts that entry
|
||||
* itself (its wrapper is statically registered — shell-bundled code, never
|
||||
* fetched), so the plugin-row loop must skip it: the vendored Group.create
|
||||
* does not deduplicate by name, and a second fiber would provide 'modules'
|
||||
* twice.
|
||||
*/
|
||||
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
|
||||
|
||||
/**
|
||||
* The web shell kernel: mounts the loading page into a DOM element and runs
|
||||
* the two-stage boot over the host graph. Fields hold only what must exist
|
||||
* before cordis does — the parsed manifest, the module system, and the
|
||||
* loading-page UI handles; everything else lives in plugins.
|
||||
*/
|
||||
export class AppWebEntry {
|
||||
private readonly el: HTMLElement
|
||||
private readonly seams: BootSeams | undefined
|
||||
private readonly status = createLoaderStatusStore()
|
||||
private readonly settled = createSignal(false)
|
||||
private readonly error = createSignal<string | undefined>(undefined)
|
||||
// Assigned by run() before any private method or settled-gated closure reads them.
|
||||
private ctx!: Context
|
||||
private modules!: ClientModuleSystem
|
||||
private manifest!: BootManifest
|
||||
private root: Root | undefined
|
||||
|
||||
/**
|
||||
* Hold the mount point; all work happens in {@link run}.
|
||||
* @param el - mount point (the app's #root).
|
||||
* @param seams - Optional module transport overrides for test environments.
|
||||
*/
|
||||
constructor(el: HTMLElement, seams?: BootSeams) {
|
||||
this.el = el
|
||||
this.seams = seams
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the boot chain to settlement. Boot-chain failures resolve (not
|
||||
* reject): the loading page stays up and renders the failure report (the
|
||||
* fail-loud surface the kernel owns). Rejects only when the boot manifest
|
||||
* is missing or malformed — there is nothing to boot against.
|
||||
* @returns resolves once the UI settled or the failure report rendered.
|
||||
*/
|
||||
async run(): Promise<void> {
|
||||
this.manifest = parseBootManifest((globalThis as DshWindow).__DSH_BOOT__)
|
||||
|
||||
this.modules = new ClientModuleSystem({
|
||||
modules: this.manifest.modules, staticModules: getStaticModules(), ...this.seams,
|
||||
})
|
||||
// The app-shell assembly is the only shell-own module: every other graph
|
||||
// row is a plugin bundle arriving through fetch.
|
||||
this.modules.registerStatic(APP_SHELL_ID, AppShell)
|
||||
// Adoption handoff, supply side: register the modules
|
||||
// package's own client half under its bare package name (= graph row id
|
||||
// = entry name — a suffixed key would miss the statics branch and
|
||||
// trigger a real fetch), and put the instance on the kernel slot the
|
||||
// wrapper's apply reads to provide ctx.modules.
|
||||
this.modules.registerStatic(MODULES_ID, ModulesClient)
|
||||
;(globalThis as DshWindow).__DSH_MODULES__ = this.modules
|
||||
|
||||
this.root = createRoot(this.el)
|
||||
this.root.render(
|
||||
<AppRoot
|
||||
settled={this.settled}
|
||||
status={this.status}
|
||||
error={this.error}
|
||||
renderApp={() => {
|
||||
const shell = this.ctx.get('appShell')
|
||||
// Unreachable after a clean settle (the app-shell entry is in every graph).
|
||||
if (shell === undefined) throw new Error('web boot: appShell service missing after settled')
|
||||
return shell.renderApp()
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
// The immediately tier prefetches in parallel with Loader mounting;
|
||||
// runPluginBoot awaits it before creating entries (see module comment:
|
||||
// cross-package synchronous require edges need every immediately-tier
|
||||
// factory registered before any materialization).
|
||||
const prefetching = this.prefetchImmediateTier()
|
||||
this.ctx = new Context()
|
||||
try {
|
||||
await this.runPluginBoot(prefetching)
|
||||
this.settled.set(true)
|
||||
} catch (reason) {
|
||||
// Stay on the loading page; surface the sweep report (fail loud).
|
||||
console.error(reason)
|
||||
this.error.set(reason instanceof Error ? reason.message : String(reason))
|
||||
}
|
||||
}
|
||||
|
||||
/** Unmount the shell (loading page or settled UI). */
|
||||
dispose(): void {
|
||||
this.root?.unmount()
|
||||
}
|
||||
|
||||
/** Prefetch the immediately tier (factory registration only; failures defer to the import path). */
|
||||
private async prefetchImmediateTier(): Promise<void> {
|
||||
await Promise.all(this.manifest.plugins
|
||||
.filter(row => row.immediately)
|
||||
.map(row => this.modules.prefetch(row.id).catch(() => {
|
||||
// Import reloads and reports this loudly per entry; swallowing
|
||||
// here keeps one failing prefetch from masking the others.
|
||||
})))
|
||||
}
|
||||
|
||||
/** Plugin face: mount the Loader, inject the `internal` contract, adopt modules, create the graph entries, settle, sweep. */
|
||||
private async runPluginBoot(prefetching: Promise<void>): Promise<void> {
|
||||
const ctx = this.ctx
|
||||
await ctx.plugin(Loader)
|
||||
const loader = ctx.loader
|
||||
// Inject the module system BEFORE any entry exists: tree.import falls back
|
||||
// to a bare dynamic import when internal is undefined, which in a browser
|
||||
// is a guaranteed loud failure — correct as a tripwire, never as a path.
|
||||
loader.internal = this.modules as never
|
||||
|
||||
// Status projection: AppRoot displays fiber truth. Every internal/status
|
||||
// transition under an entry re-projects that entry's row from its ROOT
|
||||
// fiber (child plugin fibers share the same entry).
|
||||
ctx.on('internal/status', (fiber) => {
|
||||
const entry = fiber.entry
|
||||
if (entry === undefined || entry.fiber === undefined) return
|
||||
this.status.set(entry.options.name, STATE_LABELS[entry.fiber.state])
|
||||
})
|
||||
|
||||
// Barrier before any entry exists: entry creation materializes bundles,
|
||||
// and materialization runs synchronous cross-package require edges that
|
||||
// need every immediately-tier factory already registered (module
|
||||
// comment). Resolves even when individual prefetches failed.
|
||||
await prefetching
|
||||
|
||||
// Adoption handoff, plugin side: the modules entry is created first —
|
||||
// its wrapper apply reads the kernel slot and provides ctx.modules (the
|
||||
// provide lives on the plugin face; see MODULES_ID for why the row loop
|
||||
// must then skip it).
|
||||
const rows = [MODULES_ID, ...this.manifest.plugins.map(row => row.id).filter(id => id !== MODULES_ID), APP_SHELL_ID]
|
||||
// Entry creation order carries no semantics (fiber inject waiting owns
|
||||
// activation order); creating concurrently lets non-prefetched bundle
|
||||
// loads parallelize. The app-shell assembly entry is appended by the
|
||||
// kernel: it is shell-own code (host graph rows are all plugin bundles),
|
||||
// and mounting the assembly is not a composition decision — it rides the
|
||||
// same entry lifecycle so the sweep and status cover it uniformly.
|
||||
await Promise.all(rows.map(async (name) => {
|
||||
this.status.set(name, 'loading')
|
||||
const id = await loader.create({ name })
|
||||
// A failed import leaves the entry fiberless (Entry._init logs and
|
||||
// returns); project it as failed — no fiber means no status event.
|
||||
if (loader.resolve(id).fiber === undefined) {
|
||||
this.status.set(name, 'failed')
|
||||
}
|
||||
}))
|
||||
|
||||
await loader.await()
|
||||
this.assertEntriesActive()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep every loader entry after the tree quiesced: an entry without a
|
||||
* fiber failed its import; a fiber not ACTIVE is FAILED (apply threw) or
|
||||
* PENDING (a required service never arrived — cordis inject waiting has no
|
||||
* timeout, so this sweep is the fail-loud compensation).
|
||||
*/
|
||||
private assertEntriesActive(): void {
|
||||
const ctx = this.ctx
|
||||
const failures: string[] = []
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
const name = entry.options.name
|
||||
if (entry.fiber === undefined) {
|
||||
failures.push(`${name}: import failed (see console for the import error)`)
|
||||
continue
|
||||
}
|
||||
const state = STATE_LABELS[entry.fiber.state]
|
||||
if (state === 'active') continue
|
||||
if (state === 'pending') {
|
||||
const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
|
||||
failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
||||
} else {
|
||||
failures.push(`${name}: ${state}`)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,11 @@
|
||||
/**
|
||||
* Web shell library entry. The shell's product is {@link AppWebEntry} —
|
||||
* apps/web's vite entry runs it against #root; everything else (AppRoot
|
||||
* gate, app-shell assembly entry, module-table staticModules, platform constants) is
|
||||
* internal to the boot chain. PLATFORM_MODULES is re-exported as the
|
||||
* single source of truth for the tsdown client externals projection.
|
||||
* apps/web's Vite entry runs it against #root. The boot page and fiber-state
|
||||
* projection remain internal; the static module table and its platform words
|
||||
* form the package's build-time contract.
|
||||
* @module @deepseek-ai/dsh-client-web
|
||||
*/
|
||||
|
||||
export { AppWebEntry, type BootSeams } from './boot.tsx'
|
||||
export { AppRoot, type AppRootProps } from './AppRoot.tsx'
|
||||
export { buildRenderApp, type AssemblyDeps } from './app.tsx'
|
||||
export { DocumentTitle, type DocumentTitleProps } from './DocumentTitle.tsx'
|
||||
export { APP_SHELL_ID, type AppShellService } from './app-shell.ts'
|
||||
export { AppWebEntry, type BootSeams } from './boot.ts'
|
||||
export { getStaticModules } from './seed.ts'
|
||||
export { PLATFORM_MODULES, type PlatformModule } from './platform.ts'
|
||||
export {
|
||||
STATE_LABELS, FIBER_STATE, createSignal, createLoaderStatusStore,
|
||||
type LoaderStatus, type LoaderEntryState, type KernelSignal, type KernelValueSignal, type LoaderStatusStore,
|
||||
} from './loader-status.ts'
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
/**
|
||||
* Fiber-state projection vocabulary and the kernel-owned status store for the
|
||||
* boot loading page. The status AppRoot renders is a projection of the real
|
||||
* cordis fiber states (display the truth, not a retelling) — the boot chain
|
||||
* subscribes `internal/status` and recomputes one row per loader entry.
|
||||
*
|
||||
* The store is hand-rolled here because of the shell self-sufficiency rule:
|
||||
* the snapshot-store machinery lives in the runtime PLUGIN
|
||||
* package, and the shell kernel must not value-import any plugin package —
|
||||
* the loading page has to work while (and especially when) plugins fail.
|
||||
* Fiber-state projection vocabulary for the framework-free boot page. The
|
||||
* boot chain subscribes to `internal/status` and projects the owning loader
|
||||
* entry's current state.
|
||||
* @module @deepseek-ai/dsh-client-web/src/loader-status
|
||||
*/
|
||||
import type { FiberState } from '@deepseek-ai/cordis'
|
||||
@@ -39,73 +33,3 @@ export const STATE_LABELS: Record<FiberState, LoaderEntryState> = {
|
||||
[FIBER_STATE.DISPOSED]: 'disposed',
|
||||
[FIBER_STATE.UNLOADING]: 'unloading',
|
||||
}
|
||||
|
||||
/** Per-entry state projection (AppRoot's status feed), keyed by entry name. */
|
||||
export type LoaderStatus = Record<string, LoaderEntryState>
|
||||
|
||||
/** Minimal observable snapshot the kernel components consume (useSyncExternalStore shape). */
|
||||
export interface KernelSignal<T> {
|
||||
/** Current value (stable reference between changes). */
|
||||
getSnapshot: () => T
|
||||
/**
|
||||
* Subscribe to changes.
|
||||
* @param fn - change listener.
|
||||
* @returns the unsubscribe disposer.
|
||||
*/
|
||||
subscribe: (fn: () => void) => () => void
|
||||
}
|
||||
|
||||
/** Writable one-value signal (settled flag, boot failure report). */
|
||||
export interface KernelValueSignal<T> extends KernelSignal<T> {
|
||||
/**
|
||||
* Publish a new value and notify subscribers.
|
||||
* @param next - the new value.
|
||||
*/
|
||||
set: (next: T) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a writable kernel signal.
|
||||
* @param init - initial value.
|
||||
* @returns the signal.
|
||||
*/
|
||||
export function createSignal<T>(init: T): KernelValueSignal<T> {
|
||||
let value = init
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
|
||||
set: (next) => {
|
||||
value = next
|
||||
for (const fn of [...listeners]) fn()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The boot status store: per-entry rows over a {@link KernelSignal} face. */
|
||||
export interface LoaderStatusStore extends KernelSignal<LoaderStatus> {
|
||||
/**
|
||||
* Project one entry's state (copy-on-write so getSnapshot references only
|
||||
* change on writes — useSyncExternalStore contract).
|
||||
* @param id - entry name.
|
||||
* @param state - projected fiber state.
|
||||
*/
|
||||
set: (id: string, state: LoaderEntryState) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the boot status store.
|
||||
* @returns the store (empty until the boot chain projects rows).
|
||||
*/
|
||||
export function createLoaderStatusStore(): LoaderStatusStore {
|
||||
let value: LoaderStatus = {}
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
|
||||
set: (id, state) => {
|
||||
value = { ...value, [id]: state }
|
||||
for (const fn of [...listeners]) fn()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ export const PLATFORM_MODULES = [
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-client-ui-attachment',
|
||||
'@deepseek-ai/dsh-client-schema-form',
|
||||
] as const
|
||||
|
||||
/** One platform module specifier (a seed-table key). */
|
||||
|
||||
@@ -14,8 +14,6 @@ import * as Cordis from '@deepseek-ai/cordis'
|
||||
import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import * as WebReact from '@deepseek-ai/dsh-client-web-react'
|
||||
import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import * as UiAttachment from '@deepseek-ai/dsh-client-ui-attachment'
|
||||
import * as SchemaForm from '@deepseek-ai/dsh-client-schema-form'
|
||||
import type { PlatformModule } from './platform.ts'
|
||||
|
||||
/**
|
||||
@@ -35,7 +33,5 @@ export function getStaticModules(): Record<string, unknown> {
|
||||
'@deepseek-ai/dsh-client-ui-slots': UiSlots,
|
||||
'@deepseek-ai/dsh-client-web-react': WebReact,
|
||||
'@deepseek-ai/dsh-client-ui-primitives': UiPrimitives,
|
||||
'@deepseek-ai/dsh-client-ui-attachment': UiAttachment,
|
||||
'@deepseek-ai/dsh-client-schema-form': SchemaForm,
|
||||
} satisfies Record<PlatformModule, unknown>
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AppRoot boot-gate smoke: loading page until the settled signal flips (status
|
||||
* alone never opens the gate), fail-loud entry list + boot failure report,
|
||||
* one-pass switch to the real UI. The full browser chain (real module system
|
||||
* + vendored Loader + bundles) is the e2e's job; this pins the shell-owned
|
||||
* gate semantics. Stores are the kernel-own signals production boot uses
|
||||
* (shell self-sufficiency: the loading page depends on no plugin package).
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
|
||||
afterEach(cleanup)
|
||||
import { AppRoot } from '@deepseek-ai/dsh-client-web/src/AppRoot.tsx'
|
||||
import { createLoaderStatusStore, createSignal } from '@deepseek-ai/dsh-client-web/src/loader-status.ts'
|
||||
|
||||
function mount() {
|
||||
const settled = createSignal(false)
|
||||
const error = createSignal<string | undefined>(undefined)
|
||||
const status = createLoaderStatusStore()
|
||||
let renders = 0
|
||||
const utils = render(
|
||||
<AppRoot
|
||||
settled={settled}
|
||||
status={status}
|
||||
error={error}
|
||||
renderApp={() => { renders += 1; return <div data-testid="real-ui" /> }}
|
||||
/>,
|
||||
)
|
||||
return { settled, status, error, counts: () => renders, ...utils }
|
||||
}
|
||||
|
||||
describe('AppRoot', () => {
|
||||
it('shows the loading page and never calls renderApp before settled', () => {
|
||||
const { queryByTestId, counts, getByText } = mount()
|
||||
expect(getByText('HARNESS')).toBeTruthy()
|
||||
expect(queryByTestId('real-ui')).toBeNull()
|
||||
expect(counts()).toBe(0)
|
||||
})
|
||||
|
||||
it('all-active status alone does not open the gate (settled signal is the only key)', () => {
|
||||
const { status, queryByTestId } = mount()
|
||||
act(() => {
|
||||
status.set('a', 'active')
|
||||
status.set('b', 'active')
|
||||
})
|
||||
expect(queryByTestId('real-ui')).toBeNull()
|
||||
})
|
||||
|
||||
it('lists failed entries and stays on the loading page', () => {
|
||||
const { status, getByText, queryByTestId } = mount()
|
||||
act(() => {
|
||||
status.set('@deepseek-ai/dsh-client-ui-layout', 'failed')
|
||||
status.set('ok', 'active')
|
||||
})
|
||||
expect(getByText('Failed to load plugins')).toBeTruthy()
|
||||
expect(getByText('@deepseek-ai/dsh-client-ui-layout')).toBeTruthy()
|
||||
expect(queryByTestId('real-ui')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the boot failure report even when no entry projected failed', () => {
|
||||
const { error, getByText, queryByTestId } = mount()
|
||||
act(() => { error.set('web boot: 1 entry did not activate\nx: pending (waiting for service: y)') })
|
||||
expect(getByText('Failed to load plugins')).toBeTruthy()
|
||||
expect(getByText(/waiting for service/)).toBeTruthy()
|
||||
expect(queryByTestId('real-ui')).toBeNull()
|
||||
})
|
||||
|
||||
it('flipping settled switches to the real UI in one pass', () => {
|
||||
const { settled, getByTestId, queryByText, counts } = mount()
|
||||
act(() => { settled.set(true) })
|
||||
expect(getByTestId('real-ui')).toBeTruthy()
|
||||
expect(queryByText('HARNESS')).toBeNull()
|
||||
expect(counts()).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* App-shell assembly plugin on the real machinery: bare Context + production
|
||||
* SlotRegistry + the test-runtime session/workspace doubles. Deliberately NOT
|
||||
* mounted through SlotTestRuntime — its create() installs the capturing
|
||||
* renderer and install() is boot-once; app-shell IS the production installer,
|
||||
* so this bench hands it the uninstalled service exactly as boot does.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { TestSessions, TestWorkspaces } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { Stabilizer } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import * as AppShell from '@deepseek-ai/dsh-client-web/src/app-shell.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const stabilize: Stabilizer = async (fn) => { await act(async () => { await fn() }) }
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
const slots = ctx.get('slots') as SlotRegistry
|
||||
ctx.provide('sessions', new TestSessions(stabilize, ctx))
|
||||
ctx.provide('workspaces', new TestWorkspaces(stabilize))
|
||||
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
const fiber = ctx.plugin({ inject: [...AppShell.inject], apply: AppShell.apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber }
|
||||
}
|
||||
|
||||
describe('app-shell assembly plugin', () => {
|
||||
it('installs the renderer and provides the assembled appShell face', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
slots.register({ name: 'root' }, () => <div data-testid="root-probe" />)
|
||||
const shell = ctx.get('appShell')
|
||||
expect(shell).toBeDefined()
|
||||
const view = render(<>{shell!.renderApp()}</>)
|
||||
expect(view.getByTestId('root-probe')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('assembles once: repeated renderApp calls reuse the built closure', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
slots.register({ name: 'root' }, () => <div data-testid="root-probe" />)
|
||||
const shell = ctx.get('appShell')!
|
||||
const first = render(<>{shell.renderApp()}</>)
|
||||
expect(first.getByTestId('root-probe')).toBeTruthy()
|
||||
first.unmount()
|
||||
// Second call rides the cached closure (renderApp ??=) and still renders.
|
||||
const second = render(<>{shell.renderApp()}</>)
|
||||
expect(second.getByTestId('root-probe')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('fiber dispose retracts the service and uninstalls the renderer', async () => {
|
||||
const { ctx, slots, fiber } = await bench()
|
||||
await stabilize(() => fiber.dispose())
|
||||
expect(ctx.get('appShell')).toBeUndefined()
|
||||
expect(() => slots.renderSlot('root', {})).toThrow('not installed')
|
||||
})
|
||||
})
|
||||
@@ -1,66 +1,26 @@
|
||||
/**
|
||||
* Shell base sheet contract, asserted against the CSS text on disk: base.css is
|
||||
* where the ui-theme token sheets enter the bundle, every sheet it names exists,
|
||||
* and scrollbar.css follows design-platform.css because it reads that sheet's
|
||||
* tokens.
|
||||
*/
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
/** Shell base styles stay independent from the dynamically loaded theme bundle. */
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const THEME_PACKAGE = '@deepseek-ai/dsh-client-ui-theme'
|
||||
const baseCss = readFileSync(fileURLToPath(new URL('../src/base.css', import.meta.url)), 'utf8')
|
||||
const themeManifest = JSON.parse(
|
||||
readFileSync(fileURLToPath(new URL('../../ui-theme/package.json', import.meta.url)), 'utf8'),
|
||||
) as { exports: Record<string, string>; files: string[] }
|
||||
|
||||
/**
|
||||
* Import specifiers of the sheet, in source order. Quote style and surrounding
|
||||
* whitespace are normalized away.
|
||||
* whitespace are intentionally irrelevant; duplicate imports remain visible.
|
||||
* @param css - stylesheet text.
|
||||
* @returns each `@import` target in the order the sheet lists it.
|
||||
* @returns import specifiers in declaration order.
|
||||
*/
|
||||
function importOrder(css: string): string[] {
|
||||
// The destructuring default only satisfies noUncheckedIndexedAccess; the
|
||||
// group is unconditional in the pattern.
|
||||
return [...css.matchAll(/@import\s+['"]([^'"]+)['"]/g)].map(([, specifier = '']) => specifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `<package>/styles/<file>` specifier to its source path for a
|
||||
* clean-tree test. The package build copies these sheets to their public
|
||||
* `lib/styles` export.
|
||||
* @param specifier - import specifier from base.css.
|
||||
* @returns absolute path of the file the specifier names.
|
||||
*/
|
||||
function resolveThemeSheet(specifier: string): string {
|
||||
const name = specifier.slice(`${THEME_PACKAGE}/styles/`.length)
|
||||
return fileURLToPath(new URL(`../../ui-theme/src/styles/${name}`, import.meta.url))
|
||||
}
|
||||
|
||||
const imports = importOrder(baseCss)
|
||||
|
||||
describe('web shell base.css', () => {
|
||||
it('publishes theme sheets from the built artifact plane', () => {
|
||||
expect(themeManifest.exports['./styles/*']).toBe('./lib/styles/*')
|
||||
expect(themeManifest.files).toContain('lib/styles')
|
||||
})
|
||||
|
||||
it('imports every sheet from the theme package and each one exists', () => {
|
||||
expect(imports.length).toBeGreaterThan(0)
|
||||
for (const specifier of imports) {
|
||||
expect(specifier.startsWith(`${THEME_PACKAGE}/styles/`), specifier).toBe(true)
|
||||
expect(existsSync(resolveThemeSheet(specifier)), specifier).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('imports the scrollbar sheet after the token sheet it reads', () => {
|
||||
// Both sheets bind on `body`, so with scrollbar.css first the alias tokens
|
||||
// would still resolve; the order encodes the dependency direction so a
|
||||
// later specificity or selector change cannot silently invert it.
|
||||
const platform = imports.indexOf(`${THEME_PACKAGE}/styles/design-platform.css`)
|
||||
const scrollbar = imports.indexOf(`${THEME_PACKAGE}/styles/scrollbar.css`)
|
||||
expect(platform).toBeGreaterThanOrEqual(0)
|
||||
expect(scrollbar).toBeGreaterThan(platform)
|
||||
it('leaves theme styles to the dynamic ui-theme client entry', () => {
|
||||
expect(imports).toEqual([])
|
||||
expect(baseCss).not.toContain(THEME_PACKAGE)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { BootPage } from '../src/boot-page.ts'
|
||||
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
function mount() {
|
||||
const el = document.createElement('div')
|
||||
document.body.append(el)
|
||||
return { el, page: new BootPage(el) }
|
||||
}
|
||||
|
||||
describe('BootPage', () => {
|
||||
it('draws the loading skeleton before any plugin state arrives', () => {
|
||||
const { el } = mount()
|
||||
expect(el.textContent).toContain('HARNESS')
|
||||
expect(el.textContent).toContain('Loading plugins…')
|
||||
})
|
||||
|
||||
it('keeps loading while entries are active or loading', () => {
|
||||
const { el, page } = mount()
|
||||
page.setState('a', 'active')
|
||||
page.setState('b', 'loading')
|
||||
expect(el.textContent).toContain('Loading plugins…')
|
||||
expect(el.textContent).not.toContain('Failed to load plugins')
|
||||
})
|
||||
|
||||
it('lists failed entries', () => {
|
||||
const { el, page } = mount()
|
||||
page.setState('@deepseek-ai/dsh-client-ui-layout', 'failed')
|
||||
page.setState('ok', 'active')
|
||||
page.setState('@deepseek-ai/dsh-client-ui-tool', 'failed')
|
||||
expect(el.textContent).toContain('@deepseek-ai/dsh-client-ui-layout')
|
||||
expect(el.textContent).toContain('@deepseek-ai/dsh-client-ui-tool')
|
||||
expect(el.textContent).not.toContain('ok')
|
||||
expect(el.textContent).not.toContain('Loading plugins…')
|
||||
})
|
||||
|
||||
it('shows the complete sweep report', () => {
|
||||
const { el, page } = mount()
|
||||
const report = 'web boot: 1 entry did not activate\nx: pending (waiting for service: y)'
|
||||
page.fail(report)
|
||||
page.setState('a', 'active')
|
||||
expect(el.textContent).toContain(report)
|
||||
expect(el.textContent).not.toContain('Loading plugins…')
|
||||
})
|
||||
|
||||
it('detaches on disposal', () => {
|
||||
const { el, page } = mount()
|
||||
page.dispose()
|
||||
expect(el.childNodes).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -20,21 +20,15 @@
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../ui-attachment"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../schema-form"
|
||||
"path": "../render-service"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { clientOnly } from '../tsdown.client.ts'
|
||||
|
||||
/**
|
||||
* Root-shape lib build plus a css stub: the shell's components import
|
||||
* Root-shape lib build plus a CSS stub: the boot page imports
|
||||
* .module.css/.css assets that tsc passes through untouched, so the JS under
|
||||
* lib/types references css files that do not exist there. The browser
|
||||
* consumer (apps/web) compiles src directly through vite where css is real;
|
||||
|
||||
Generated
+74
-57
@@ -1282,12 +1282,18 @@ importers:
|
||||
'@deepseek-ai/dsh-client-modules':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/modules
|
||||
'@deepseek-ai/dsh-client-render-service':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/render-service
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/runtime
|
||||
'@deepseek-ai/dsh-client-ui-agent-preset':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/ui-agent-preset
|
||||
'@deepseek-ai/dsh-client-ui-attachment':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/ui-attachment
|
||||
'@deepseek-ai/dsh-client-ui-commands':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/ui-commands
|
||||
@@ -1572,6 +1578,42 @@ importers:
|
||||
specifier: workspace:^
|
||||
version: link:../../runtime-diagnostics/invariants
|
||||
|
||||
packages/client/render-service:
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-client-test-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../../test-support/client-runtime
|
||||
'@deepseek-ai/dsh-client-ui-layout':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-layout
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
'@deepseek-ai/dsh-client-web-react':
|
||||
specifier: workspace:^
|
||||
version: link:../web-react
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../runtime-diagnostics/invariants
|
||||
'@types/react':
|
||||
specifier: ~18.3.1
|
||||
version: 18.3.31
|
||||
'@types/react-dom':
|
||||
specifier: ~18.3.0
|
||||
version: 18.3.7(@types/react@18.3.31)
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
|
||||
packages/client/runtime:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
@@ -1642,19 +1684,6 @@ importers:
|
||||
specifier: ~18.3.1
|
||||
version: 18.3.31
|
||||
|
||||
packages/client/schema-form:
|
||||
dependencies:
|
||||
'@deepseek-ai/schemastery':
|
||||
specifier: link:../../../vendor/schemastery
|
||||
version: link:../../../vendor/schemastery
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../runtime-diagnostics/invariants
|
||||
|
||||
packages/client/ui-agent-preset:
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
@@ -1702,25 +1731,28 @@ importers:
|
||||
|
||||
packages/client/ui-attachment:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-attachment':
|
||||
specifier: workspace:^
|
||||
version: link:../../attachment/attachment
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-primitives
|
||||
clsx:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.1
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-attachment':
|
||||
specifier: workspace:^
|
||||
version: link:../../attachment/attachment
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-client-ui-conversation':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-conversation
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-primitives
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../runtime-diagnostics/invariants
|
||||
@@ -1730,6 +1762,12 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ~18.3.0
|
||||
version: 18.3.7(@types/react@18.3.31)
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
|
||||
packages/client/ui-commands:
|
||||
dependencies:
|
||||
@@ -1782,9 +1820,6 @@ importers:
|
||||
|
||||
packages/client/ui-conversation:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../../settings/settings
|
||||
'@deepseek-ai/schemastery':
|
||||
specifier: link:../../../vendor/schemastery
|
||||
version: link:../../../vendor/schemastery
|
||||
@@ -1819,9 +1854,6 @@ importers:
|
||||
'@deepseek-ai/dsh-client-test-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../../test-support/client-runtime
|
||||
'@deepseek-ai/dsh-client-ui-attachment':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-attachment
|
||||
'@deepseek-ai/dsh-client-ui-input-trigger':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-input-trigger
|
||||
@@ -1864,6 +1896,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session-stats':
|
||||
specifier: workspace:^
|
||||
version: link:../../session/session-stats
|
||||
'@deepseek-ai/dsh-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../../settings/settings
|
||||
'@deepseek-ai/dsh-token-meter':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/token-meter
|
||||
@@ -2249,9 +2284,6 @@ importers:
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-client-schema-form':
|
||||
specifier: workspace:^
|
||||
version: link:../schema-form
|
||||
'@deepseek-ai/dsh-client-test-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../../test-support/client-runtime
|
||||
@@ -2406,9 +2438,9 @@ importers:
|
||||
|
||||
packages/client/ui-settings:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:^
|
||||
version: link:../connection
|
||||
'@deepseek-ai/schemastery':
|
||||
specifier: link:../../../vendor/schemastery
|
||||
version: link:../../../vendor/schemastery
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
@@ -2416,12 +2448,12 @@ importers:
|
||||
'@deepseek-ai/dsh-api-remotes':
|
||||
specifier: workspace:^
|
||||
version: link:../../api/remotes
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:^
|
||||
version: link:../connection
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-client-schema-form':
|
||||
specifier: workspace:^
|
||||
version: link:../schema-form
|
||||
'@deepseek-ai/dsh-client-test-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../../test-support/client-runtime
|
||||
@@ -2513,9 +2545,6 @@ importers:
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-client-schema-form':
|
||||
specifier: workspace:^
|
||||
version: link:../schema-form
|
||||
'@deepseek-ai/dsh-client-test-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../../test-support/client-runtime
|
||||
@@ -3068,21 +3097,12 @@ importers:
|
||||
'@deepseek-ai/dsh-client-modules':
|
||||
specifier: workspace:^
|
||||
version: link:../modules
|
||||
'@deepseek-ai/dsh-client-schema-form':
|
||||
specifier: workspace:^
|
||||
version: link:../schema-form
|
||||
'@deepseek-ai/dsh-client-ui-attachment':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-attachment
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-primitives
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
'@deepseek-ai/dsh-client-ui-theme':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-theme
|
||||
'@deepseek-ai/dsh-client-web-react':
|
||||
specifier: workspace:^
|
||||
version: link:../web-react
|
||||
@@ -3099,12 +3119,9 @@ importers:
|
||||
'@deepseek-ai/cordis-plugin-loader':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/loader
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
'@deepseek-ai/dsh-client-render-service':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-client-test-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../../test-support/client-runtime
|
||||
version: link:../render-service
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../runtime-diagnostics/invariants
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* CSS Modules enter client bundles through virtual modules, so the loader must
|
||||
* explicitly register the underlying stylesheet as a watch dependency.
|
||||
* Stylesheets enter client bundles through virtual modules, so the loader must
|
||||
* register their physical files as watch dependencies.
|
||||
*/
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -14,7 +14,7 @@ interface CssPlugin {
|
||||
load?: (this: { addWatchFile(id: string): void }, id: string) => Promise<string | null>
|
||||
}
|
||||
|
||||
function cssPlugin(): CssPlugin {
|
||||
function cssPlugin(name: 'dsh-css-modules-inline' | 'dsh-css-global-inline' | 'dsh-css-text-inline'): CssPlugin {
|
||||
const configs = clientBundle(
|
||||
'@deepseek-ai/dsh-client-test',
|
||||
['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
@@ -22,8 +22,8 @@ function cssPlugin(): CssPlugin {
|
||||
const client = configs.find(config => config.platform === 'browser')
|
||||
if (client === undefined) throw new Error('client config missing')
|
||||
const plugins = (client as { plugins: CssPlugin[] }).plugins
|
||||
const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline')
|
||||
if (plugin === undefined) throw new Error('CSS Modules plugin missing from client config')
|
||||
const plugin = plugins.find(candidate => candidate.name === name)
|
||||
if (plugin === undefined) throw new Error(`${name} missing from client config`)
|
||||
return plugin
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('client bundle CSS Modules', () => {
|
||||
const stylesheet = join(root, 'Fixture.module.css')
|
||||
const importer = join(root, 'index.ts')
|
||||
await writeFile(stylesheet, '.root { color: red; }\n')
|
||||
const plugin = cssPlugin()
|
||||
const plugin = cssPlugin('dsh-css-modules-inline')
|
||||
const virtualId = plugin.resolveId?.('./Fixture.module.css', importer)
|
||||
if (typeof virtualId !== 'string' || plugin.load === undefined) {
|
||||
throw new Error('CSS Modules plugin hooks are incomplete')
|
||||
@@ -50,3 +50,51 @@ describe('client bundle CSS Modules', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('client bundle global CSS', () => {
|
||||
it('compiles a side-effect stylesheet into a watched style injector', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-client-global-css-watch-'))
|
||||
try {
|
||||
const stylesheet = join(root, 'base.css')
|
||||
const importer = join(root, 'index.ts')
|
||||
await writeFile(stylesheet, 'body { color: red; }\n')
|
||||
const plugin = cssPlugin('dsh-css-global-inline')
|
||||
const virtualId = plugin.resolveId?.('./base.css', importer)
|
||||
if (typeof virtualId !== 'string' || plugin.load === undefined) {
|
||||
throw new Error('global CSS plugin hooks are incomplete')
|
||||
}
|
||||
const watched: string[] = []
|
||||
|
||||
const output = await plugin.load.call({ addWatchFile: id => watched.push(id) }, virtualId)
|
||||
|
||||
expect(watched).toEqual([stylesheet])
|
||||
expect(output).toContain('data-plugin-css')
|
||||
expect(output).toContain('body{color:red}')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('compiles inline stylesheets as watched text without a module side effect', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-client-inline-css-watch-'))
|
||||
try {
|
||||
const stylesheet = join(root, 'base.css')
|
||||
const importer = join(root, 'index.ts')
|
||||
await writeFile(stylesheet, 'body { color: red; }\n')
|
||||
const plugin = cssPlugin('dsh-css-text-inline')
|
||||
const virtualId = plugin.resolveId?.('./base.css?inline', importer)
|
||||
if (typeof virtualId !== 'string' || plugin.load === undefined) {
|
||||
throw new Error('inline CSS plugin hooks are incomplete')
|
||||
}
|
||||
const watched: string[] = []
|
||||
|
||||
const output = await plugin.load.call({ addWatchFile: id => watched.push(id) }, virtualId)
|
||||
|
||||
expect(watched).toEqual([stylesheet])
|
||||
expect(output).toContain('export default "body{color:red}"')
|
||||
expect(output).not.toContain('data-plugin-css')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -135,7 +135,8 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract',
|
||||
launchEnvironment: 'not a service: launcher-provided root accessor value (LaunchEnvironmentSnapshot | undefined) — packages/util/launch-environment/README.md owns this launcher contract',
|
||||
connection: 'interface-typed (HostConnectionHandle); implementing class HostConnectionService is declared in rpc-host.ts — packages/client/connection/README.md owns the API',
|
||||
appShell: 'client-side interface-typed browser service — packages/client/web/README.md owns the API',
|
||||
appShell: 'client-side interface-typed browser service — packages/client/render-service/README.md owns the API',
|
||||
settingsSchema: 'client-side schema introspection service — packages/client/ui-settings/README.md owns the API',
|
||||
settingsScope: 'client-side settings-namespace transport service — packages/client/ui-settings/README.md owns the API',
|
||||
chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the API',
|
||||
commandUi: 'client-side interface-typed browser service — packages/client/ui-commands/README.md owns the API',
|
||||
|
||||
@@ -65,7 +65,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/client/ui-attachment': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/schema-form': { kind: 'none', reason: 'Browser-side form-rendering library; registers nothing model-facing.' },
|
||||
'packages/client/render-service': { kind: 'none', reason: 'Browser-side render assembly; registers nothing model-facing.' },
|
||||
'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and identity policy; selected services own any model-visible effect.' },
|
||||
'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
|
||||
+3
-2
@@ -174,9 +174,10 @@
|
||||
"@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"],
|
||||
"@deepseek-ai/dsh-client-ui-attachment": ["./packages/client/ui-attachment/src"],
|
||||
"@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"],
|
||||
"@deepseek-ai/dsh-client-schema-form": ["./packages/client/schema-form/src"],
|
||||
"@deepseek-ai/dsh-client-schema-form/invariant": ["./packages/client/schema-form/src/invariant.ts"],
|
||||
"@deepseek-ai/dsh-client-web-react": ["./packages/client/web-react/src"],
|
||||
"@deepseek-ai/dsh-client-render-service": ["./packages/client/render-service/src"],
|
||||
"@deepseek-ai/dsh-client-render-service/client": ["./packages/client/render-service/src/client"],
|
||||
"@deepseek-ai/dsh-client-render-service/invariant": ["./packages/client/render-service/src/invariant.ts"],
|
||||
"@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"],
|
||||
"@deepseek-ai/dsh-api-remotes": ["./packages/api/remotes/src"],
|
||||
"@deepseek-ai/dsh-api-remotes/client": ["./packages/api/remotes/src/client/index.ts"],
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
// rootDir rules make both paths depend on this runtime project reference.
|
||||
{ "path": "./packages/compaction/compaction" },
|
||||
{ "path": "./packages/client/ui-slots" },
|
||||
{ "path": "./packages/client/schema-form" },
|
||||
{ "path": "./packages/client/ui-attachment" },
|
||||
{ "path": "./packages/client/ui-primitives" },
|
||||
{ "path": "./packages/client/web-react" },
|
||||
@@ -92,6 +91,7 @@
|
||||
{ "path": "./packages/client/ui-settings-models" },
|
||||
{ "path": "./packages/client/ui-settings-plugin-inventory" },
|
||||
{ "path": "./packages/client/locale" },
|
||||
{ "path": "./packages/client/render-service" },
|
||||
{ "path": "./packages/client/web" },
|
||||
{ "path": "./apps/web" }
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user