Merge branch 'worktree/web-carrier-chain' into worktree/web-ask-user-question

Restack the ask-user domain layer onto the carrier-chain architecture
branch. Conflict policy: runtime and ui-conversation take the
carrier-chain side (master sessions shape, dual-kind PendingCard,
'internal' envelope shell tests); the 'cancelled' wire code and its
semantics tests stay in apiproxy + ui-question (domain layer); the
smoke fixture keeps the nine-bundle success pass with the resident
question round over the carrier-chain first-describe shape.
This commit is contained in:
imccyu
2026-07-23 18:50:30 +08:00
141 changed files with 5200 additions and 2360 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-19-gui-web-client-architecture.md: f21b840493b83c02d7abc3ba1c1bf90635166ec1
2026-07-19-gui-web-client-architecture.zh.md: a50dc556cfc96b6d35feea6ef2b1aadae9f31c44
2026-07-19-gui-web-client-architecture.md: 6e1cbc2d1e3e3437480c8005ca06845c23c628df
2026-07-19-gui-web-client-architecture.zh.md: 9e2b3ef60d97840cd6cbd26e8fdcf922d472391c
@@ -49,9 +49,9 @@ Implementation homes: registry core and the props-share types in `packages/clien
## Services and scope addressing
A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-map merges). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/views/startSession), `ctx.toolviews` (named per-tool render registry with per-session scope filters). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md).
A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md).
Beyond SlotMap, two more typed registration rings follow the same declare-merge idiom: the **view ring** (`ConversationViewMap` — an entry may declare `chromeProps`/`extraProps` extension shapes; `ConvViewPropsOf<Id>`/`ChromePropsOf<Id>` compose base + extension, so a view with no declaration gets the base for free while ui-trajectory's entries carry real per-view props) and the **tool ring** (tool names stay an open set — no global key table; typing hardens inside the entry: `ToolViewProps.block` is the real `ToolCallBlock` union defined in runtime, and register infers the registrant's injected share like slots do).
There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`; the owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`), and `ToolRowProps` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`, with `inject: ['slots', 'conversation']` as the load-order seam (the conversation service being present guarantees the slot is declared). Session-dimension differentiation happens inside the component — `useSessions` reading `parentId` — not in registry predicates; interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early.
**Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport).
@@ -103,16 +103,16 @@ src/client/
service.ts cross-domain orchestration (imports contract only)
skeleton/ domain: shell components (ConversationRoot/InputBar/EmptyState/DetailsPanel)
chat/ domain: the chat view
toolviews/ domain: the tool-row registry and samples
toolviews/ domain: sample tool-row registrants (third-party posture)
apply.ts the ONLY file allowed to import across domains (assembly point)
index.ts thin re-export shell (contract + apply + components)
```
Domain implementation files never import a sibling domain — shared surfaces route through `contract/` (e.g. chat consumes the tool registry through a `ToolViewResolver` read-face interface, not the registry class). `scripts/verify-client-domain-graph.ts` enforces the layering (contract=0, domains=1, apply/index=2; imports may only point at levels ≤ own; sibling-domain edges fail). A future package split promotes each domain directory to a package and mechanically rewrites import paths.
Domain implementation files never import a sibling domain — shared surfaces route through `contract/` (e.g. the toolviews samples take `ToolRowProps` from the contract, never chat internals). `scripts/verify-client-domain-graph.ts` enforces the layering (contract=0, domains=1, apply/index=2; imports may only point at levels ≤ own; sibling-domain edges fail). A future package split promotes each domain directory to a package and mechanically rewrites import paths.
## How to develop
- **A new UI feature** = a new plugin package: declare `dshClient` (+ `inject` topology) in package.json, write the browser half under `src/client/` (apply mounts services/stores, registers slots and toolviews), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically.
- **A new UI feature** = a new plugin package: declare `dshClient` (+ `inject` topology) in package.json, write the browser half under `src/client/` (apply mounts services/stores and registers slots), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically.
- **A new slot**: see the [slot system standard RFC](2026-07-22-slot-type-chain-implementation.md) — merge the contract into `SlotMap`, declare it in the parent entry's `children`, render through the auto-injected `renderSlot` prop. Never export components globally.
- **Consuming a new frame type**: sessionId-bearing → a branch in Session's dispatch switch; host-level → the Manager routing table; if the UI needs it, a `ConversationSnapshot` field with the reference discipline kept.
- **Where does this state live**: business data (events, streaming, pending) → always the object layer; what the parent knows → owner props at the renderSlot site; private to one component (scroll, search text, expansion) → component state; shared across entries or surviving remounts (selection, drafts, panel widths) → an entry-declared store ([slot system standard](2026-07-22-slot-type-chain-implementation.md)).
@@ -129,5 +129,5 @@ Token streams no longer shake the render tree: a frame storm costs unsubscribed
| One statically-linked SPA bundle | Plugins must be host-composable at runtime (config-driven); a monolith re-couples every UI feature to one build |
| window globals / import maps for shared deps | The DI require table keeps sharing explicit, fail-loud, and swappable; globals leak identity and version silently |
| Business data in zustand slices | The event window/accumulator is a behavioral state machine, not a flat slice; the object layer keeps snapshot granularity and batching controllable |
| String-keyed global component registry for tool rows | Tool views are consumed by multiple views and need per-session differentiation — a named service (`ctx.toolviews`) with scope filters is the honest shape |
| String-keyed global component registry for tool rows | Per-view keyed child slots plus in-component session branching carry the same need with the one registration model; a parallel registry does not come back ([toolview dissolution](2026-07-23-toolview-dissolution.md)) |
| Progressive/Suspense boot in P-I | One-flip boot is strictly simpler; the loader's per-plugin status face is kept so progressive lighting can land later without re-architecture |
@@ -49,9 +49,9 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain-
## 服务与 scope 寻址
服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只 merge 视图表)。名册:`ctx.connection`api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader``ctx.theme``ctx.i18n``ctx.layout`(跨插件视图导航)、`ctx.conversation`send/cancel/views/startSession`ctx.toolviews`(具名按工具渲染注册表,带按会话 scope 过滤)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。
服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader``ctx.theme``ctx.i18n``ctx.layout`(跨插件视图导航)、`ctx.conversation`send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。
SlotMap 之外还有两条同 declare-merge 惯例的类型化注册环:**视图环**(`ConversationViewMap`——entry 可声明 `chromeProps`/`extraProps` 扩展形状;`ConvViewPropsOf<Id>`/`ChromePropsOf<Id>` 组合基座+扩展,无声明的视图免费得基座,ui-trajectory 的两个 entry 带真 per-view props)与**工具环**tool 名保持开放集——无全局键表;类型强化在 entry 内部:`ToolViewProps.block` 是 runtime 定义的真 `ToolCallBlock` unionregister 同 slots 一样推断注册方注入份额)
slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entrytab 元数据随注册 options`id`/`order`/`label`)走,per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`keyed/session),由 chat 条目的 `children` 表声明;key 空间运行时开放(SlotMap 声明槽、从不声明 key),这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`owner 载荷是统一的 `ToolRowOwnerProps``callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝(conversation 服务在场即保证槽已声明)。会话维差异化在组件内完成——`useSessions``parentId`——不走注册表谓词;交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明
**scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。
@@ -103,16 +103,16 @@ src/client/
service.ts cross-domain orchestration (imports contract only)
skeleton/ domain: shell components (ConversationRoot/InputBar/EmptyState/DetailsPanel)
chat/ domain: the chat view
toolviews/ domain: the tool-row registry and samples
toolviews/ domain: sample tool-row registrants (third-party posture)
apply.ts the ONLY file allowed to import across domains (assembly point)
index.ts thin re-export shell (contract + apply + components)
```
域实现文件永不 import 兄弟域——共享面一律走 `contract/`(如 chat 经 `ToolViewResolver` 读面接口消费工具注册表,不碰注册表类)。`scripts/verify-client-domain-graph.ts` 把守分层(contract=0、域=1、apply/index=2import 只准指向 ≤ 自己的层级;兄弟域边即失败)。将来拆包=每个域目录升格为包+机械改写 import 路径。
域实现文件永不 import 兄弟域——共享面一律走 `contract/`(如 toolviews 样例从契约取 `ToolRowProps`,永不碰 chat 内部)。`scripts/verify-client-domain-graph.ts` 把守分层(contract=0、域=1、apply/index=2import 只准指向 ≤ 自己的层级;兄弟域边即失败)。将来拆包=每个域目录升格为包+机械改写 import 路径。
## 怎么开发
- **新 UI 功能** = 新插件包:package.json 声明 `dshClient`+ `inject` 拓扑),浏览器半边写在 `src/client/`apply 挂服务/建 store、注册 slot 与 toolview),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;清单与装载随之自动跟上。
- **新 UI 功能** = 新插件包:package.json 声明 `dshClient`+ `inject` 拓扑),浏览器半边写在 `src/client/`apply 挂服务/建 store、注册 slot),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;清单与装载随之自动跟上。
- **新 slot**:见 [slot 体系标准 RFC](2026-07-22-slot-type-chain-implementation.md)——契约合并进 `SlotMap`,在父 entry 的 `children` 里声明,经自动注入的 `renderSlot` prop 渲染。永不全局导出组件。
- **消费新帧类型**:带 sessionId → Session 分发 switch 加一个分支;host 级 → Manager 路由表;UI 需要时给 `ConversationSnapshot` 加字段并守住引用纪律。
- **状态住哪**:业务数据(事件、流式、待答)→ 永远对象层;父知道的 → renderSlot 现场的 owner props;单组件私有(滚动、搜索词、展开集)→ 组件状态;跨 entry 共享或跨重挂载存活(选中、草稿、面板宽)→ entry 声明的 store[slot 体系标准](2026-07-22-slot-type-chain-implementation.md))。
@@ -129,5 +129,5 @@ token 流不再震荡渲染树:帧风暴对未订阅会话只花一个脏位
| 静态链接的单 SPA bundle | 插件必须由 host 在运行时按配置组合;单体把每个 UI 功能重新耦回一次构建 |
| window 全局变量 / import map 供共享依赖 | DI require 表让共享显式、大声失败、可替换;全局变量静默泄漏身份与版本 |
| 业务数据进 zustand 切片 | 事件窗口/累积器是行为状态机,不是扁平切片;对象层保住快照粒度与合批的可控性 |
| 工具行走字符串键的全局组件注册表 | 工具视图被多个视图共同消费且要按会话差异化——带 scope 过滤的具名服务(`ctx.toolviews`)才是诚实形态 |
| 工具行走字符串键的全局组件注册表 | per-view keyed 子槽 + 组件内会话分支以唯一注册模型承载同一需求;平行 registry 不复活([toolview 溶解](2026-07-23-toolview-dissolution.md) |
| P-I 就做渐进/Suspense 启动 | 一次成型严格更简单;loader 的按插件状态面已保留,渐进点亮日后可落地而无需重构 |
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-22-tui-interactive-extension-service.md: 82e7c751b6e5b7500f9f7d7004fda8b905dccabb
2026-07-22-tui-interactive-extension-service.zh.md: d7340e3f5dcf45e95b2d6e15ce3fc33726a555ae
@@ -0,0 +1,41 @@
# Agent Note: Effect-owned TUI interactive extensions
Status: implemented
English | [中文](2026-07-22-tui-interactive-extension-service.zh.md)
## Problem
Cordis plugins can register human commands through `ctx.commands`, but a command that needs terminal interaction has no supported presentation boundary. It must either remain non-interactive or capture the TUI's private pi-tui tree, focus state, renderer, and shutdown lifecycle. That coupling makes the extension depend on one front door's internals, lets independently developed overlays compete for focus, and leaves plugin unload with no reliable way to remove queued or visible UI.
## Decision
A mounted `@deepseek-ai/dsh-tui` provides `ctx.tui` after terminal startup succeeds. The service belongs to that exact terminal and agent, disappears before terminal teardown, and causes plugins that inject it to unload and reload with provider availability. Other front doors do not emulate it.
`ctx.tui.openOverlay()` is the first and only interactive extension primitive. It accepts a component factory, constrained layout options, and an optional abort signal. The factory receives a frozen host with the current viewport, semantic theme functions, display-text escaping, redraw, close, and a lifetime signal. It does not receive the pi-tui `TUI`, overlay handle, editor, transcript tree, focus controller, or terminal object.
One private overlay manager serializes built-in and plugin requests in FIFO order. The model selector and `ctx.userInteraction` question panel use the same manager, so all modal interaction has one focus owner. Closing the active overlay restores pi-tui's previous focus before the next request activates. Overlay state is process-local presentation: it is neither appended to the session log nor rebuilt during resume.
The service method runs through Cordis's traceable service proxy. It installs an effect on the calling plugin fiber before admitting the request; caller disposal therefore removes a queued request or closes an active overlay and awaits the same settled outcome. TUI shutdown first rejects admission, then disposes the service fiber so dependent plugins and their effects quiesce, settles remaining built-in work, and only then drains and stops the terminal.
Component construction, rendering, input, and invalidation run behind an exception boundary. A failure closes that request with an `error` outcome, reports a visible terminal error, and lets the queue continue. Components are trusted package code: their rendered lines may contain ANSI styling, and they must call `host.display()` before including untrusted text.
## Verification
Manager tests pin FIFO admission, cancellation, repeated close, shutdown outcomes, guarded callbacks, host capabilities, and per-file coverage. Cordis lifecycle tests pin caller ownership, provider loss and return, unloading-time rejection, and cleanup quiescence. Fake-terminal integration tests exercise plugin overlays alongside built-in questions, restored editor input, terminal remount, startup rollback, and service disappearance. Existing TUI interaction tests continue to exercise the model selector and question panel through the shared path.
## Alternatives considered
**Expose pi-tui objects directly.** This gives plugins maximum freedom but makes private focus, rendering, and teardown state a public compatibility contract. It also cannot arbitrate independently loaded overlays.
**Put interactive callbacks on command definitions.** Commands are shared by TUI and ACP and remain useful without a terminal. Adding terminal state to `ctx.commands` would couple discovery and dispatch to one presentation implementation.
**Create a complete TUI slot and action framework at once.** Actions, editor replacement, transcript renderers, status regions, and completion providers have different composition and conflict rules. Shipping them behind one broad API would freeze those rules before a concrete consumer proves them.
**Persist open overlays in session events.** Modal presentation is not model-visible session state, and arbitrary component state is not replayable. The plugin that owns durable data records that data through its domain service and recreates presentation when appropriate.
## Consequences
Interactive plugins gain a small stable front door with deterministic focus and Cordis-owned cleanup, while the TUI keeps authority over terminal lifecycle and pi-tui internals. Built-in dialogs and extensions cannot overlap or strand focus.
The API deliberately covers modal overlays only. Human command registration remains on `ctx.commands`; actions, slots, editor replacement, event renderers, and completion providers require separate contracts when real consumers establish their ordering and ownership semantics. FIFO serialization also means one stalled overlay blocks later modal work until its owner closes, aborts, or unloads it.
@@ -0,0 +1,41 @@
# Agent Note: 由 effect 持有的 TUI 交互扩展
Status: implemented
[English](2026-07-22-tui-interactive-extension-service.md) | 中文
## 问题
Cordis 插件可以通过 `ctx.commands` 注册用户命令,但需要终端交互的命令没有受支持的呈现边界。它只能保持非交互,或者捕获 TUI 私有的 pi-tui 树、焦点状态、渲染器和关闭生命周期。此类耦合会使扩展依赖某个入口的内部实现,让各自独立开发的浮层争抢焦点,并导致插件卸载时无法可靠移除排队中或已显示的 UI。
## 决策
挂载的 `@deepseek-ai/dsh-tui` 在终端成功启动后提供 `ctx.tui`。该服务只属于挂载时绑定的终端与 agent(智能体),在终端拆卸前消失,并使注入它的插件随着提供方的可用与否卸载和重新加载。其他入口不会模拟该服务。
`ctx.tui.openOverlay()` 是第一个也是唯一一个交互扩展原语。它接受组件工厂、受限的布局选项,以及可选的中止信号。工厂收到一个冻结的 host,其中包含当前视口、语义化主题函数、显示文本转义、重绘、关闭和生命周期信号。它不会收到 pi-tui `TUI`、浮层句柄、编辑器、transcript(文本记录)树、焦点控制器或终端对象。
一个私有浮层管理器按 FIFO 顺序串行处理内置请求和插件请求。模型选择器与 `ctx.userInteraction` 问题面板使用同一个管理器,因此所有模态交互只有一个焦点所有者。关闭活动浮层时,系统会先恢复 pi-tui 之前的焦点,再激活下一项请求。浮层状态是进程本地的呈现状态:它既不会追加到会话日志,也不会在恢复期间重建。
服务方法通过 Cordis 的可追踪服务代理运行。它在接纳请求前,向调用方插件的 fiber 注册一个 effect;因此,调用方执行 dispose(资源释放)时会移除排队中的请求或关闭活动浮层,并等待同一个结果完成结算。TUI 关闭时会先拒绝新请求,再 dispose 服务 fiber,让依赖插件及其 effect 完全静止,然后结算其余内置工作,最后才排空并停止终端。
组件构造、渲染、输入与失效处理均在异常边界内运行。任何失败都会以 `error` 结果关闭对应请求、在终端中报告一条可见错误,并让队列继续处理。组件属于受信任的包代码:其渲染行可以包含 ANSI 样式,但加入不受信任的文本前必须调用 `host.display()`
## 验证
管理器测试固定了 FIFO 准入、取消、重复关闭、关闭结果、受保护回调、host 能力和逐文件覆盖率。Cordis 生命周期测试固定了调用方所有权、提供方消失与恢复、卸载期间的拒绝,以及清理达到完全静止。模拟终端集成测试覆盖插件浮层与内置问题的协作、编辑器输入焦点恢复、终端重新挂载、启动回滚和服务消失。既有 TUI 交互测试继续通过共享路径覆盖模型选择器与问题面板。
## 考虑过的替代方案
**直接暴露 pi-tui 对象。** 这会赋予插件最大的自由度,却会把私有的焦点、渲染与拆卸状态变成公开兼容性契约,也无法在独立加载的浮层之间进行仲裁。
**在命令定义中加入交互回调。** 命令由 TUI 与 ACP 共享,即使没有终端也仍然有用。向 `ctx.commands` 添加终端状态,会让发现与分派流程耦合到某一种呈现实现。
**一次性建立完整的 TUI slot 与 action 框架。** action、编辑器替换、transcript 渲染器、状态区域和补全提供方具有不同的组合规则与冲突规则。在具体消费方验证这些规则之前就将其纳入一个宽泛 API,会过早固化这些规则。
**将打开的浮层持久化为会话事件。** 模态呈现并非模型可见的会话状态,任意组件状态也无法回放。拥有持久数据的插件应通过自身的领域服务记录这些数据,并在适当时重新创建呈现。
## 后果
交互式插件获得一个小而稳定的入口,具备确定性的焦点管理和由 Cordis 持有的清理机制;TUI 则继续掌控终端生命周期和 pi-tui 内部实现。内置对话框与扩展无法重叠,也不会遗留失去归属的焦点。
该 API 有意只覆盖模态浮层。用户命令仍然在 `ctx.commands` 上注册;action、slot、编辑器替换、事件渲染器和补全提供方需要另行设计契约,等待实际消费方确定其顺序与所有权语义。FIFO 串行处理也意味着,一个停滞的浮层会阻塞后续模态工作,直至其所有者关闭、中止或卸载该浮层。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-23-toolview-dissolution.md: a420c5945d0272cf8087d5f623e9c383c286d7c2
2026-07-23-toolview-dissolution.zh.md: 47c1f392f5f7ddbf4e6c686b2574faa7987e6126
@@ -0,0 +1,37 @@
# Agent Note: Toolview dissolution — tool rows are per-view keyed slots
Status: implemented
English | [中文](2026-07-23-toolview-dissolution.zh.md)
> Scope: why the standalone tool ring (ToolViewRegistry/ctx.toolviews/outlet) was retired and what replaced it. The [web client architecture note](2026-07-19-gui-web-client-architecture.md) carries the shipped-state narrative this decision produced; the [slot system standard](2026-07-22-slot-type-chain-implementation.md) owns the registration model everything now runs on.
## Problem
After the view ring dissolved into the slot system, the client kept exactly one parallel registration model: the tool ring — a named registry (`ctx.toolviews`) with its own register grammar, its own resolve semantics (scoped-beats-global predicate dispatch), its own subscribe/version pair, its own inject cache, and its own render outlet with a private error boundary. Every one of those was a second implementation of something the slot machinery already owned, and every future capability (a store seat for row drafts, i18n injection, cross-bundle identity) would have had to be built twice or drift. The ring's one honest justification was that tool names are a runtime-open set while `SlotMap` is a closed declaration table — a registry keyed by arbitrary strings seemed structurally necessary.
## Decision
The tool ring is gone as independent infrastructure: a tool row is a **keyed child slot each view declares for itself**, and the client has exactly one registration model. The justification above was hollow — a keyed slot's *key space* is already runtime-open (SlotMap declares slots, never keys; the ask-user composer's `key: 'question'` was the precedent), so the open tool-name set fits `entryKey` dispatch natively.
Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam — apply mounts `ConversationService` *after* the chat registration, so the service being present guarantees the slot is declared, by construction. Session-dimension differentiation happens inside the component (`useSessions` reading `parentId` — the decision sits where all the information already is); the bash sample is the third-party-posture exemplar. Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations.
Registry-era responsibilities all have successor homes: inject caching and row error isolation ride the framework renderer (entry×scope cache, per-entry `SlotErrorBoundary`); subscribe/getVersion ride the slot core's per-key version machinery; the future "store seat" is the ordinary store seat keyed slots already have (interaction-draft durability is its first named consumer); miss fallback is the call-site `fallback` option.
## Accepted semantic changes
Four behavioral deltas were accepted deliberately, not overlooked. Cross-view appearance is per-view registration — a row must adapt to each view's layout anyway, so one registration per view is the correct coupling, and reuse is the same component in two register calls. Same-key double registration is a loud throw where the registry let later-wins silently override — a discipline correction, not a loss. Session-dimension dispatch moved from registry predicates into the component. Registry-level shape override by third parties (a scoped registration shadowing a global one) has no equivalent; a real future need routes through key-naming conventions or a small in-component resolver, never a revived parallel registry.
## Alternatives considered
**Keep the standalone registry (the original shape).** Rejected: each of its multi-dimensional dispatch axes has a more correct home — the view dimension belongs to each view's own declared child slot (declaring is claiming, so specialization ownership lands right), and the session dimension belongs inside the component, which already holds the standard kit. What remained after both moves was a second copy of slot machinery with no distinguishing capability.
**Promote `renderToolView` into the standard kit and move the registry into the runtime package.** Rejected: "tool row" is a conversation-domain concept; hoisting it into runtime would leak a domain vocabulary into the framework layer and still leave two registration models.
**Derive slot declarations from subscription refCounts** (declare the slot implicitly when the first registrant subscribes). Rejected for implicit coupling and debounce complexity; noted as a possible revisit only if a genuinely multi-viewer surface appears.
**A thin `registerToolView` facade over slots.register.** Deferred, not rejected: after dissolution the facade would carry only compile-time sugar (slot-name literal narrowing, tool→key vocabulary, props pre-composition) with zero runtime. Per "enforce at the operation boundary" (a facade is not an enforcement point) and "don't split preemptively" (today's registrant population is one bash sample), it stays unbuilt; the type sugar ships as the exported `ToolRowProps` alias. Regret clause: if registrants grow to three-to-five or a bulk-registration pattern appears, the facade is ten lines added without disturbing direct registration.
## Consequences
The client has one registration model; auditing who renders tool rows = reading register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above (chiefly: per-view registration for cross-view rows, and no third-party registry-level override), plus one subtlety the load-order seam carries: registrant plugins must declare `inject: ['conversation']` to sequence after the slot declaration, a convention the seam makes correct by construction but does not statically force on third parties.
@@ -0,0 +1,37 @@
# Agent Note: toolview 溶解——工具行即 per-view keyed slot
Status: implemented
[English](2026-07-23-toolview-dissolution.md) | 中文
> 范围:独立工具环(ToolViewRegistry/ctx.toolviews/outlet)为何退役、被什么取代。本决策产出的落地态叙述归 [Web 客户端架构注](2026-07-19-gui-web-client-architecture.md);一切现在所运行其上的注册模型归 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 所有。
## Problem
视图环溶解进 slot 体系之后,client 侧恰好还剩一套平行注册模型:工具环——一个具名注册表(`ctx.toolviews`),带自己的 register 文法、自己的 resolve 语义(scoped 压 global 的谓词分发)、自己的 subscribe/version 对、自己的 inject 缓存、自己带私有错误边界的渲染出口。其中每一件都是 slot 机器已经拥有之物的第二份实现,而每一项未来能力(行草稿的 store 席位、i18n 注入、跨 bundle 身份)都将不得不建两遍或漂移。这条环唯一像样的存在理由是:tool 名是运行时开放集,而 `SlotMap` 是封闭声明表——以任意字符串为键的注册表看似结构上必需。
## Decision
工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**,client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放(SlotMap 声明槽、从不声明 keyask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。
落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps``callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。会话维差异化在组件内完成(`useSessions``parentId`——决策放在已有全部信息的地方);bash 样例即第三方姿态的样板。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。
registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘框架渲染器(entry×scope 缓存、per-entry `SlotErrorBoundary`);subscribe/getVersion 乘 slot core 的 per-key 版本机;将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位(交互草稿耐久性是其首个具名消费者);miss 兜底即调用点 `fallback` 选项。
## 接受的语义变化
四项行为增量是刻意接受而非疏漏。跨视图出场=逐视图注册——行本须适配各视图版式,一视图一注册是正确耦合,复用即同一组件写两次 register。同 key 重复注册从注册表的 later-wins 静默覆盖变为 loud throw——纪律修正而非损失。会话维分发从注册表谓词移入组件。第三方在 registry 级覆盖形态(scoped 注册压过 global)不复存在;真出现的未来需求走 key 命名空间约定或组件内小 resolver,永不复活平行注册表。
## Alternatives considered
**保留独立注册表(原形态)。** 拒绝:其多维分发的每一维都有更正确的家——视图维归各视图自己声明的子槽(declaring is claiming,特化面权属自然落对),会话维归已持有标配 kit 的组件内部。两步移完后剩下的只是一份没有任何独有能力的 slot 机器副本。
**把 `renderToolView` 提进标配 kit、注册表迁入 runtime 包。** 拒绝:「工具行」是 conversation 域概念;上提进 runtime 会把域词汇泄漏进框架层,且依然留着两套注册模型。
**以订阅 refCount 推导槽声明**(首个注册方订阅时隐式声明槽)。拒绝:隐式耦合加去抖复杂度;记为将来真出现多观看面时的备选。
**slots.register 之上的薄 `registerToolView` 门面。** 缓建而非拒绝:溶解后该门面只剩编译期三糖(槽名字面量收窄、tool→key 词汇翻译、props 预组合),运行时为零。按「enforce at the operation boundary」(门面不是强制点)与「don't split preemptively」(今天注册方人口只有一个 bash 样例)保持不建;类型糖以导出的 `ToolRowProps` 别名兑现。后悔药条款:注册方长到三五家或出现批量注册模式时,门面十行可补,不扰直注。
## Consequences
client 只有一种注册模型;审计谁渲染工具行 = 读 register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化(主要是:跨视图行要逐视图注册、第三方无 registry 级覆盖),外加加载序缝携带的一处微妙:注册方插件须声明 `inject: ['conversation']` 才排在槽声明之后,这条约定由序缝构造保证正确、但不对第三方静态强制。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-20-config-hot-reload-resilience.md: 1a8e29c603ede50b60199e9151fca58dadcc3d40
2026-07-20-config-hot-reload-resilience.zh.md: 6c7a421bfa84504a36d5329e13a485bf72cc6b6c
@@ -0,0 +1,38 @@
# Agent Note: A config hot-reload must not kill or degrade a live app
Status: implemented
English | [中文](2026-07-20-config-hot-reload-resilience.zh.md)
## Problem
The demo apps mount `@cordisjs/plugin-hmr` as a leaf so a running agent picks up `cordis.yml` edits. One bad edit killed the process: `Include.refresh()` rethrew the YAML parse error, the HMR watcher awaits `refresh()` inside an async chokidar callback nobody catches, and the resulting unhandled rejection tripped `dsh-app-boot`'s fail-loud handler — `exit(1)` mid-session, losing the live TUI. Two adjacent defects made even *valid* reloads wrong: a file that parses to `undefined` (empty or mid-write truncated — editors and `sed -i` routinely produce these states) crashed the entry walk instead of reading as invalid, and a re-read never re-applied the include's `config.patches`, so any hot-reload of an overlay-based tree (Code Mode, personal overlays) silently reverted patched entries and removed inserted ones.
## Decision
Harden the vendored `@cordisjs/plugin-include` (logged as local modification 8 in [vendor/README.md](../../../../vendor/README.md)) rather than the callers:
- `refresh()` awaits the whole read-and-update and catches failures, logs a warning, and keeps the last good entry tree. A hot-reload is advisory; the invariant is that no file state reachable by an editor may take the process down.
- `read()` rejects a non-array parse result with a `TypeError`, folding the `undefined`-parse case into the same "invalid file" signal, and commits `content`/`data` only after a successful parse — so reverting an edit to the exact last good content correctly reads as "unchanged".
- `refresh()` and the `internal/update` listener apply `this.applyPatches(...)` before `root.update()`, restoring parity with `[Service.init]`. `applyPatches` deep-copies the cached parse (`structuredClone`) instead of mutating it, so repeated application converges and removing a patch reverts to the file's own values. The listener uses the incoming config's `patches` and persists that config itself: it vetoes the fiber restart (children update in place), and `Fiber.update` only assigns `this.config` behind `next()`, so without the explicit assignment the next re-read would re-apply the old overlay.
Boot-time behavior stays fail-loud and gets a sharper diagnostic: `[Service.init]` falls back to `initial` (or "config file not found") only on `ENOENT`; an existing-but-invalid file now fails with its real parse error instead of being mislabelled as absent or silently overwritten by `initial`.
## Alternatives considered
**Catch in the HMR watcher callback instead of `refresh()`.** Rejected: it would leave `refresh()` a trap for every other caller (the `internal/update` path shares the same tree-update logic), and it cannot fix the `undefined`-parse or patch-loss defects, which live inside the include.
**Filter config-file rejections in `installFailLoud`.** Rejected: the fail-loud handler exists to make late load failures visible; teaching it to classify exceptions by origin would silently swallow genuine boot failures and leave the stale-`data` crash in place.
**A PTY e2e proving the TUI survives a bad edit.** Rejected as the primary gate: the PTY smoke reads the repo's committed `cordis.yml`, so corrupting it in-place is not test-safe, and a temp copy cannot resolve the tree's bare package specifiers. The unit spec drives the exact `refresh()` entry point the watcher calls; the fix was additionally verified manually against the live TUI (bad YAML, empty file, restored file).
## Consequences
- A bad `cordis.yml` edit now logs `ignoring config reload at <file>` and the agent keeps running on the last good tree; the next valid edit applies normally. With no logger exporter mounted in the TUI demos the warning is currently invisible on screen — surfacing loader warnings in the TUI is deferred.
- Overlay trees survive base-file reloads with patches intact instead of silently reverting to the unpatched base.
- The vendored include diverges further from upstream; the divergence is logged in the vendor manifest and re-applies on the next sync.
- Known gap, out of scope here: the HMR watcher only handles chokidar `change` events, so editors that replace the file by rename (BSD `sed -i`, `git checkout`) do not trigger a config reload at all; and a reloaded app-entry config does not visibly restart the running TUI (pre-existing on the unmodified tree).
## Testing
`packages/ui/app-boot/tests/config-reload.spec.ts` boots real Loader trees against temp configs and pins: an invalid-YAML edit and an empty-file edit both resolve `refresh()` without rejection and keep the previous entry config; a subsequent valid edit applies; an overlay tree re-applies both entry patches and inserted entries on re-read; a hot-update of the include entry's own `patches` applies immediately, survives the next file re-read, and reverts cleanly when the patches are removed. The assertions fail on the unpatched vendored include.
@@ -0,0 +1,38 @@
# Agent Note: 配置热重载不得杀死或降级正在运行的应用
Status: implemented
[English](2026-07-20-config-hot-reload-resilience.md) | 中文
## Problem
各示例应用把 `@cordisjs/plugin-hmr` 挂载为叶子配置项,让运行中的 agent 能感知 `cordis.yml` 的编辑。一次错误的编辑就会杀死进程:`Include.refresh()` 把 YAML 解析错误原样抛出,HMR 的文件监听器在一个无人捕获的异步 chokidar 回调里 await `refresh()`,产生的未处理 rejection 触发 `dsh-app-boot` 的快速失败处理器——会话中途 `exit(1)`,正在运行的 TUI 就此丢失。另有两个相邻缺陷让*合法*的重载也出错:解析结果为 `undefined` 的文件(空文件或写入中途被截断的文件——编辑器和 `sed -i` 常态性地产生这类中间状态)会让配置项遍历直接崩溃,而不是被判定为无效文件;并且重新读取时从不重新应用 include 的 `config.patches`,因此对基于 overlay 的配置树(Code Mode、个人 overlay)做任何热重载,都会悄悄把打过补丁的配置项回退、并把插入的配置项移除。
## Decision
加固 vendor 的 `@cordisjs/plugin-include`(在 [vendor/README.md](../../../../vendor/README.md) 中记录为本地修改第 8 条),而不是修改调用方:
- `refresh()` await 整个「读取并更新」过程并捕获失败,记录一条警告,并保留上一份完好的配置树。热重载是尽力而为的;不变式是编辑器可能产生的任何文件状态都不得导致进程退出。
- `read()` 对非数组的解析结果抛出 `TypeError`,把 `undefined` 解析结果并入同一个「无效文件」信号,并且只在解析成功后才提交 `content`/`data`——因此把编辑撤销回与上一份完好内容完全一致时,会正确地判定为「无变化」。
- `refresh()``internal/update` 监听器在 `root.update()` 之前调用 `this.applyPatches(...)`,与 `[Service.init]` 保持一致。`applyPatches` 对缓存的解析结果做深拷贝(`structuredClone`)而不是就地修改,因此重复应用会收敛,移除补丁会回退到文件自身的值。监听器使用传入配置中的 `patches` 并自行持久化该配置:它否决 fiber 重启(子配置项就地更新),而 `Fiber.update` 只在 `next()` 之后才赋值 `this.config`,若不显式赋值,下一次重新读取会重新应用旧的 overlay。
启动期行为保持快速失败并获得更准确的诊断:`[Service.init]` 只在 `ENOENT` 时回退到 `initial`(或「config file not found」);存在但无效的文件现在会以真实的解析错误失败,而不是被误标为文件缺失、或被 `initial` 静默覆盖。
## Alternatives considered
**在 HMR 监听回调里捕获,而不是在 `refresh()` 里。** 否决:这会让 `refresh()` 继续成为其他所有调用方的陷阱(`internal/update` 路径共享同一套树更新逻辑),而且无法修复 `undefined` 解析结果与补丁丢失这两个位于 include 内部的缺陷。
**在 `installFailLoud` 里过滤配置文件相关的 rejection。** 否决:快速失败处理器的存在意义就是让延迟出现的加载失败可见;教它按来源给异常分类会悄悄吞掉真正的启动失败,并且原样保留陈旧 `data` 导致的崩溃。
**用 PTY e2e 证明 TUI 能在错误编辑后存活。** 否决其作为主要门禁:PTY 冒烟测试读取仓库中已提交的 `cordis.yml`,就地破坏它对测试不安全,而临时副本无法解析该配置树的裸包说明符。单元测试直接驱动监听器所调用的 `refresh()` 入口;此外还对运行中的 TUI 做了人工验证(错误 YAML、空文件、恢复文件)。
## Consequences
- 现在错误的 `cordis.yml` 编辑会记录 `ignoring config reload at <file>`,agent 继续运行在上一份完好的配置树上;下一次合法编辑正常生效。TUI 示例没有挂载任何日志导出器,这条警告目前不会显示在屏幕上——在 TUI 中呈现 loader 警告的工作暂缓。
- overlay 配置树在基础文件重载后补丁保持完整,不再悄悄回退到未打补丁的基础配置。
- vendor 的 include 与上游进一步分叉;该分叉已记录在 vendor 的 manifest 里,下次同步时重新应用。
- 已知缺口,不在本次范围内:HMR 监听器只处理 chokidar 的 `change` 事件,因此通过重命名替换文件的编辑方式(BSD `sed -i``git checkout`)完全不会触发配置重载;应用配置项重载后也不会可见地重启运行中的 TUI(未修改的代码树上即已如此)。
## Testing
`packages/ui/app-boot/tests/config-reload.spec.ts` 用真实 Loader 树加载临时配置并固定以下行为:无效 YAML 编辑和空文件编辑都让 `refresh()` 正常 resolve 而不产生 rejection,并保留之前的配置项配置;随后的合法编辑正常生效;overlay 配置树在重新读取时重新应用配置项补丁和插入的配置项;对 include 配置项自身 `patches` 的热更新立即生效、在下一次文件重读后依然保持、并在补丁移除后干净地回退。这些断言在未打补丁的 vendor include 上会失败。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-22-collapsed-sidebar-control-rail.md: e959eef37a9e9c0fea79b82ff970daddd9257609
2026-07-22-collapsed-sidebar-control-rail.zh.md: 7f6d6529a8aa4a655a1d3292e7f41bfb822f05a3
@@ -0,0 +1,29 @@
# Agent Note: A collapsed sidebar retains its control rail
Status: implemented
English | [中文](2026-07-22-collapsed-sidebar-control-rail.zh.md)
## Problem
The sidebar close action persisted a zero width preference, and the layout mapped that preference to a zero-width grid track. The only sidebar toggle and the settings entry both lived inside that clipped track, so closing the sidebar removed every visible recovery control. Reloading preserved the closed preference and reproduced the lockout.
## Decision
The layout maps a closed sidebar (persisted width `0`) to the fixed `SIDEBAR_COLLAPSED` width of 56px: a 24px icon column between the sidebar's 16px horizontal paddings. The compact rail participates in the concession solver and retains its right border, while the stored expanded width remains untouched.
`AppFrame` marks the sidebar collapsed from the persisted width preference rather than from the resolved track width, removes the resize handle while collapsed, and passes `collapsed` to the sidebar slot as owner props from the render site. Collapse and expand animate: the frame transitions `grid-template-columns` (and the remaining handle its `left`) on the deepsuite sider curve — `--ds-ease-in-out` over `--ds-transition-duration-slow`, both supplied by ui-theme's base sheet; transitions pause during drags and under `prefers-reduced-motion`.
`SidebarRoot` reads the owner `collapsed` prop and morphs in place rather than swapping renders: the four control rows persist into the rail — expand toggle, new session, new workspace, search, in the same top-down order as their expanded rows — animating their geometry (heights, paddings, margins, capsule borders) on the same curve, each aligned with its expanded counterpart's behavior (the search icon expands the sidebar and focuses the search box). Wide-only content (brand, labels, input, session tree) cross-fades out over 200ms, stays mounted while the collapse animates, and unmounts once the 300ms settle passes — dropping the sessions subscription and leaving the rendered and accessibility trees. The search query lives with the root and survives the round trip.
## Alternatives considered
- **Render an expand button over the center column** — rejected because it recovers only the toggle, not the persistent settings area, and splits sidebar chrome across two package owners.
- **Keep a zero-width grid track and let the rail overflow it** — rejected because the rail would overlap the center column and leave hit testing and responsive geometry disconnected from the grid.
- **Keep the complete sidebar tree mounted and hide it with clipping** — rejected because hidden controls remain in the semantic tree and continue subscribing and rendering even though only two controls belong in the collapsed state.
## Consequences
- A collapsed sidebar reserves 56px instead of yielding the entire width to the center column. Expanding restores the persisted width and drag behavior.
- The settings entry remains visible but retains its existing placeholder behavior; this change does not introduce an account or settings screen.
- Layout solver tests pin the compact width, sidebar component tests pin the visible controls, and the keyless real-bundle web smoke test pins collapse and recovery through the assembled client.
@@ -0,0 +1,29 @@
# Agent Note: 侧边栏折叠后保留控制栏
Status: implemented
[English](2026-07-22-collapsed-sidebar-control-rail.md) | 中文
## 问题
侧边栏关闭操作会持久化宽度偏好 `0`,布局再将该偏好映射为宽度为零的网格轨道。侧边栏唯一的开关与设置入口都位于这个被裁切的轨道内,因此关闭侧边栏会移除所有可见的恢复控件。页面重新加载时仍会读取关闭偏好,从而再次陷入无法恢复的状态。
## 决策
布局将关闭的侧边栏(持久化宽度为 `0`)映射为固定的 `SIDEBAR_COLLAPSED` 宽度 56px:在侧边栏两侧各 16px 的水平内边距之间放置一列 24px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。
`AppFrame` 根据持久化的宽度偏好标记侧边栏是否折叠,而不是根据求解后的轨道宽度来判断;折叠时移除尺寸调整手柄,并在渲染点把 `collapsed` 作为 owner props 传给侧边栏插槽。折叠与展开带动画:frame 对 `grid-template-columns`(以及余下手柄的 `left`)应用 deepsuite 侧栏曲线过渡——`--ds-ease-in-out``--ds-transition-duration-slow`,两个变量由 ui-theme 的 base 表提供;拖拽期间和 `prefers-reduced-motion` 下过渡暂停。
`SidebarRoot` 读取 owner 的 `collapsed` 属性,原地 morph 而非切换渲染:四个控件行持续存在并演变为控制栏——展开开关、新建会话、新建工作区、搜索,自上而下与展开态各行顺序一致——几何(行高、内边距、外边距、胶囊边框)走同一条曲线动画,行为与展开态对应控件对齐(搜索图标会展开侧边栏并聚焦搜索框)。宽态专属内容(品牌标识、文字标签、输入框、会话树)以 200ms 交叉淡出,折叠动画期间保持挂载,300ms settle 后卸载——随之退订会话列表并离开渲染树与可访问性树。搜索关键词由根组件持有,折叠往返后保留。
## 曾考虑的替代方案
- **在中心列上方渲染展开按钮**:不予采纳,因为这只能恢复开关,无法保留常驻设置区域,同时还会让侧边栏 UI 由两个包(package)分别持有。
- **保留宽度为零的网格轨道,让控制栏溢出显示**:不予采纳,因为控制栏会与中心列重叠,还会使命中测试和响应式几何关系脱离网格布局。
- **保持完整侧边栏树挂载,并通过裁切将其隐藏**:不予采纳,因为隐藏控件仍留在语义树中,而且会继续订阅和渲染,尽管折叠状态下只需要两个控件。
## 后果
- 折叠的侧边栏占用 56px,而不是把全部宽度让给中心列。展开时恢复持久化宽度与拖动行为。
- 设置入口持续可见,但保留既有占位行为;本次改动不提供账户或设置页面。
- 布局求解器测试固定紧凑宽度,侧边栏组件测试固定可见控件,基于真实构建产物的无密钥 Web 冒烟测试则通过组装后的客户端固定折叠与恢复行为。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-23-thinking-row-disclosure-target.md: f698c3cb0b73bf5c65b5d4b5b3f29de3080e0af6
2026-07-23-thinking-row-disclosure-target.zh.md: 0fba5c1d8f7beec7300dcd51e118a08d57d0e74f
@@ -0,0 +1,29 @@
# Agent Note: Thinking rows use one disclosure target
Status: implemented
English | [中文](2026-07-23-thinking-row-disclosure-target.zh.md)
## Problem
A collapsed reasoning entry presents `Think` and its one-line reasoning summary as one visual row, but an icon-only disclosure control leaves both visible labels inert. Applying title expansion to every tool row would instead break the generic tool-row contract, where the row opens details and only the leading control expands arguments.
## Decision
`ToolRow` exposes the opt-in `expandOnRowClick` policy. `ThinkRow` enables it so the title and reasoning summary form one accessible disclosure target; pointer clicks, Enter, and Space toggle the same component-local expanded state. Tool rows that do not opt in retain row-to-details selection and leading-control argument expansion.
## Verification
The component spec pins both Think click targets and the unchanged generic tool-row handoff. The keyless browser fixture loads the real sidebar and conversation bundles, opens an authored reasoning session, clicks the summary and title, and checks the disclosure state and expanded body.
## Alternatives considered
**Expand every tool row from its title.** Generic tool rows use row clicks for details selection, so sharing this behavior would conflate two controls.
**Keep icon-only disclosure.** The smallest hit target remains disconnected from the labels that describe the hidden content.
**Render separate title and summary buttons.** Two controls for one expanded state add duplicate focus stops and ambiguous semantics.
## Consequences
Thinking rows gain a larger pointer target and keyboard disclosure semantics without changing other tool interactions. The generic row component carries one optional policy because disclosure ownership differs between reasoning and tool calls.
@@ -0,0 +1,29 @@
# Agent Note: thinking 行使用单一展开目标
Status: implemented
[English](2026-07-23-thinking-row-disclosure-target.md) | 中文
## 问题
折叠的推理(reasoning)条目在同一视觉行中呈现 `Think` 和单行推理摘要,但仅图标可展开会让两个可见标签都无法交互。若让所有工具行均可通过标题展开,又会破坏通用工具行的契约:整行负责打开详情,只有前导控件负责展开参数。
## 决策
`ToolRow` 提供显式启用的 `expandOnRowClick` 策略。`ThinkRow` 启用该策略,让标题和推理摘要组成单一且无障碍的展开目标;鼠标点击、Enter 和 Space 都切换同一个组件本地展开状态。未启用该策略的工具行仍由整行完成详情选择,由前导控件展开参数。
## 验证
组件测试固定两个 Think 点击目标以及未改变的通用工具行交接行为。无密钥浏览器 fixture(测试前置数据)加载真实的侧边栏与会话 bundle,打开包含推理内容的既定会话,点击摘要与标题,并检查展开状态和展开后的正文。
## 考虑过的替代方案
**让每个工具行都可通过标题展开。** 通用工具行将整行点击用于详情选择,共享这一行为会混淆两个控件。
**保留仅图标展开。** 最小的点击目标仍与描述隐藏内容的标签脱节。
**把标题和摘要分别渲染为按钮。** 两个控件共享一个展开状态,会增加重复的焦点停靠点并产生含糊语义。
## 后果
thinking 行获得更大的鼠标点击目标和键盘展开语义,同时不改变其他工具交互。通用行组件承担一个可选策略,因为推理与工具调用的展开所有权不同。
@@ -12,7 +12,7 @@ The lifecycle has two distinct classes of content. The initial applicable chain
## Decision
The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. `@deepseek-ai/dsh-agent-core` mounts it for both product front doors and forwards its config. The plugin consumes `agent/session-prefix`, `tools/post-execute`, and the optional `ctx.fs` capability.
The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/session-prefix`, `tools/post-execute`, and the optional `ctx.fs` capability.
The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes resolve each path and stat the result, so a final-component symlink is followed to its target: a link to a regular file loads, while a missing path or a non-file target is a confirmed absence. Following repository-owned links across the trust boundary is a deliberate reversal of the original no-follow probe; the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns that decision and its residual risk. The session-prefix signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. A resolve or stat exception is classified as unavailable: it skips only that candidate and is never interpreted as the deletion of an already-loaded scope.
@@ -76,7 +76,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc
## Consequences
Workspace guidance is isolated per session and shared by both product front doors and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract carries JSON metadata propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries.
Workspace guidance is isolated per session and shared by the demo front doors, Web Host, and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract carries JSON metadata propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries.
Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, and delimiter escaping reduce risk but do not eliminate prompt injection. Following a candidate symlink to its target widens that surface to off-tree content, so the permission and sandbox layers that confine `ctx.fs` to trusted roots are the boundary that treats workspace files as data rather than authority (the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns the residual risk).
@@ -117,7 +117,7 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s
- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, narrator coalescing, ACP advertisement and validation, and turn-enclosed config writes.
- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. The real ACP composition pins permission switching and rejects unknown presets. CI rejects a silent all-skip.
- **With-key:** drive a real model, runner, bridge answerer, and disk effect through granted and rejected escalation; unavailable credentials or runners self-skip.
- **With-key:** start the real ACP composition in read-only mode, let a model-driven bash write hit the runner's denial marker, then drive the bridge answerer and disk effect through granted and rejected workspace-write retries; unavailable credentials or runners self-skip.
- **Snapshot:** pin the permission config-option wire, preset and knob events, prompt deltas and notices, and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins a successful workspace-write mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. Other snapshots start unconfined so unrelated fixtures remain platform-independent, and policy scenarios switch explicitly.
## Deferred phases
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-20-dsh-cli-personal-config.md: e349374a6bc7fc0137bf14836469aef8bae8d49d
2026-07-20-dsh-cli-personal-config.zh.md: 88210dc386a245002de927950dab2852e40218ea
2026-07-20-dsh-cli-personal-config.md: 514bb5b12a3e04c7deaad1e8616472eed1c920e1
2026-07-20-dsh-cli-personal-config.zh.md: 16fada82c59c8a356e6df112234e6b7565aae1bf
@@ -22,6 +22,8 @@ Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh
The PTY smoke's launcher isolates `$DSH_HOME` to a per-test directory, exactly as it already isolates `DSH_AGENTS_HOME`, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes.
Hot-reload interplay: the include re-applies its `patches` on every config re-read (the [config hot-reload resilience Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)), so a live `cordis.yml` edit keeps the personal overlay applied.
## Alternatives considered
**A standalone `bin/dsh` wrapper owning the `dsh` name.** Rejected after reading PR #443: that PR establishes `apps/cli` as the `dsh` CLI with subcommand dispatch (`web`, `-p`) and leaves the default slot unclaimed. Two competing `dsh` entrypoints would collide in `$PATH` and in product identity; claiming the default slot inside the same package shape confines the eventual merge conflict to the small dispatch chain.
@@ -22,6 +22,8 @@ Status: implemented
PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。
与热重载的交互:include 在每次配置重读时重新应用其 `patches`(见[配置热重载韧性 Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)),因此运行中编辑 `cordis.yml` 后个人 overlay 仍保持生效。
## Alternatives considered
**独立的 `bin/dsh` 包装脚本占有 `dsh` 这个名字。** 读过 PR #443 后否决:该 PR 把 `apps/cli` 确立为带子命令分发(`web``-p`)的 `dsh` CLI,并且默认位空缺。两个互相竞争的 `dsh` 入口会在 `$PATH` 和产品身份上冲突;在同一包形态内认领默认位,把最终的合并冲突限制在小小的分发链上。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-21-tui-reload-command.md: de9a5502214a610d88024730b1c0c1044a396c92
2026-07-21-tui-reload-command.zh.md: 25d1d448459221698ca63377f8f18d05a0fa3d21
2026-07-21-tui-reload-command.md: e5600f0ab5cd82dc556df76006fcf532d8c7d302
2026-07-21-tui-reload-command.zh.md: 3798b0518df1c379cca808bd4af38490016567cb
@@ -10,7 +10,7 @@ HMR's file watcher only reacts to in-place `change` events under its configured
## Decision
`dsh-tui` gains an **experimental, dev-only** `/reload` slash command: it walks `ctx.loader.entries()` and calls `refresh()` on every file-backed subtree (`Include`), i.e. the exact code path the HMR watcher's config-change branch drives, invoked manually and watcher-independent. Unchanged files are no-ops (content comparison in `Include.read`).
`dsh-tui` gains an **experimental, dev-only** `/reload` slash command: it walks `ctx.loader.entries()` and calls `refresh()` on every file-backed subtree (`Include`), i.e. the exact code path the HMR watcher's config-change branch drives, invoked manually and watcher-independent. Unchanged files are no-ops (content comparison in `Include.read`); invalid files warn and keep the running tree (the hot-reload-resilience contract); include `patches` — including the dsh CLI's personal overlay — re-apply on every re-read.
The TUI reaches the Loader **structurally** (`ctx.loader` via a local type, not `inject`): tests and embedders run the TUI without a Loader, where `/reload` degrades to a warning notice instead of failing the mount. Module-source hot reload stays watcher-owned; `/reload` refreshes configs only.
@@ -28,8 +28,8 @@ The TUI reaches the Loader **structurally** (`ctx.loader` via a local type, not
- The command reports tree count and completion as transcript notices; per-file failures surface only in loader logs, which the TUI does not display — acceptable for a dev-only surface, noted in the completion message.
- A re-entrancy guard serializes reloads: `/reload` while one is in flight is refused with a warning, keeping the loader's unmutexed tree-update pass single-writer; the guard releases on completion or failure.
- `/reload` runs only while the agent is idle: a reload can dispose and re-mount entries, which under an active turn could tear tools or the adapter out from under in-flight calls. The check is advisory (a send can race in after it) but removes the common footgun.
- If any `refresh()` rejects, the command reports the failure instead of leaving an unhandled rejection.
- If `refresh()`'s never-reject contract ever changes, the command reports the failure instead of leaving an unhandled rejection.
## Testing
`packages/ui/tui/tests/tui.spec.ts` pins: `/reload` refreshes every file-backed subtree and skips plain entries (structural fake Loader), reports completion, refuses re-entry while a gated refresh is in flight and runs again after release, releases the guard on the failure arm, refuses a running agent and runs again at idle, reports a rejecting refresh, and degrades to a warning without a Loader — including mounted as a real plugin fiber, where a throwing service lookup would escape. Verified live in tmux against the real tree: a probe edit reloads successfully.
`packages/ui/tui/tests/tui.spec.ts` pins: `/reload` refreshes every file-backed subtree and skips plain entries (structural fake Loader), reports completion, refuses re-entry while a gated refresh is in flight and runs again after release, releases the guard on the failure arm, refuses a running agent and runs again at idle, reports a rejecting refresh, and degrades to a warning without a Loader — including mounted as a real plugin fiber, where a throwing service lookup would escape. Verified live in tmux against the real tree: probe edit reload applies; invalid edit → reload keeps the running tree.
@@ -10,7 +10,7 @@ HMR 的文件监听器只对其配置根目录(示例中即配置叶子所在
## Decision
`dsh-tui` 增加一个**实验性、仅供开发**的 `/reload` 斜杠命令:遍历 `ctx.loader.entries()`,对每个文件后端的子树(`Include`)调用 `refresh()`——即 HMR 监听器配置变更分支所走的同一条代码路径,改为手动触发、不依赖监听器。未变化的文件是无操作(`Include.read` 做内容比较)。
`dsh-tui` 增加一个**实验性、仅供开发**的 `/reload` 斜杠命令:遍历 `ctx.loader.entries()`,对每个文件后端的子树(`Include`)调用 `refresh()`——即 HMR 监听器配置变更分支所走的同一条代码路径,改为手动触发、不依赖监听器。未变化的文件是无操作(`Include.read` 做内容比较);无效文件记录警告并保留运行中的树(热重载韧性契约);include 的 `patches`——包括 dsh CLI 的个人 overlay——在每次重读时重新应用
TUI 以**结构方式**访问 Loader(通过局部类型访问 `ctx.loader`,而非 `inject`):测试和嵌入方在没有 Loader 的情况下运行 TUI,此时 `/reload` 退化为一条警告通知而不是挂载失败。模块源码热重载仍由监听器负责;`/reload` 只刷新配置。
@@ -28,8 +28,8 @@ TUI 以**结构方式**访问 Loader(通过局部类型访问 `ctx.loader`
- 命令以 transcript 通知报告树数量与完成;单文件失败只出现在 loader 日志里,TUI 不显示——对仅供开发的表面可以接受,完成消息中已注明。
- 重入保护串行化重载:前一次进行中时 `/reload` 会被拒绝并提示警告,使 loader 无互斥的树更新过程保持单写者;保护在完成或失败时释放。
- `/reload` 只在 agent 空闲时运行:重载可能卸载并重新挂载配置项,在活跃轮次下这会把工具或适配器从进行中的调用脚下抽掉。检查是建议性的(检查后仍可能有 send 竞争进来),但消除了常见的坑。
- 任一 `refresh()` reject,命令会报告失败而不是留下未处理的 rejection。
- `refresh()` 的永不 reject 契约将来改变,命令会报告失败而不是留下未处理的 rejection。
## Testing
`packages/ui/tui/tests/tui.spec.ts` 固定:`/reload` 刷新每个文件后端子树并跳过普通配置项(结构化的假 Loader)、报告完成、在门控的刷新进行中拒绝重入并在释放后可再次运行、失败分支同样释放保护、拒绝运行中的 agent 并在空闲后可再次运行、报告 reject 的 refresh、无 Loader 时退化为警告——包括作为真实插件 fiber 挂载的情形,在那里会抛出的服务查找会泄露出去。已在 tmux 中对真实配置树实机验证:探针编辑 reload 成功生效。
`packages/ui/tui/tests/tui.spec.ts` 固定:`/reload` 刷新每个文件后端子树并跳过普通配置项(结构化的假 Loader)、报告完成、在门控的刷新进行中拒绝重入并在释放后可再次运行、失败分支同样释放保护、拒绝运行中的 agent 并在空闲后可再次运行、报告 reject 的 refresh、无 Loader 时退化为警告——包括作为真实插件 fiber 挂载的情形,在那里会抛出的服务查找会泄露出去。已在 tmux 中对真实配置树实机验证:探针编辑 reload 生效;无效编辑 → reload 保留运行中的树
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-21-serial-cross-platform-ci-reference.md: ffc1fd5b37bc6c9e3427ee55a55300f93a1292f3
2026-07-21-serial-cross-platform-ci-reference.zh.md: d7f87916865b83973abe6b0708203618cf536c8e
2026-07-21-serial-cross-platform-ci-reference.md: b795a0aff62c20967d2c85429c0c6115c1b9585d
2026-07-21-serial-cross-platform-ci-reference.zh.md: 223fd9cf20a1d8228cb0c6b1b2f3f95644becae6
@@ -6,7 +6,7 @@ English | [中文](2026-07-21-serial-cross-platform-ci-reference.zh.md)
## Problem
The pull-request workflow reaches its latency targets by scheduling the complete primary Node inventory concurrently inside one larger runner. The optimized scheduler still should not be its own only completeness oracle: a defect in its gate inventory or dependency graph could omit work while the optimized job stays green.
The pull-request workflow consolidates required checks into dedicated Linux and Windows jobs. Those jobs still should not be the only completeness oracle: a defect in their gate inventory or dependency graph could omit work while the required aggregate stays green.
Encoding the one-minute non-Windows target and three-minute Windows target as job timeouts creates a separate failure mode. Hosted-runner startup and performance vary, so a correct gate can be cancelled at the target boundary before it emits useful diagnostics. The performance objective needs measurement against GitHub timestamps, while correctness needs enough time to finish.
@@ -14,21 +14,21 @@ Reviewers also need a direct answer to a simpler question: what happens when the
## Decision
[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run only the optimized larger-runner and compatibility jobs. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The three operating-system jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace.
Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only the optimized jobs; a master push runs only the three serial references. The one-minute non-Windows and three-minute Windows objectives are evaluated from completed hosted-job timestamps and reported as measurements; they are not `timeout-minutes` values.
Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the three serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. A higher-core hosted runner remains a possible future benchmark, but it is not the default: larger runners require organization-owned labels and provisioning, while a reference oracle should remain runnable without repository-external runner configuration. Provisioning one later can change the performance experiment without changing this correctness baseline.
The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. Required pull-request jobs use the same portable Linux and Windows capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
## Alternatives considered
- **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression.
- **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check.
- **Run the serial references on every pull request** - rejected because they deliberately trade wall time and runner consumption for simplicity and are not needed in the fast feedback loop.
- **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and Windows contracts.
- **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism.
- **Run the serial reference on larger runners** - rejected because the reference is the portable fallback for the organization-specific pull-request topology. The fast pull-request path uses provisioned larger runners; the serial master path keeps standard labels.
- **Run the serial reference on larger runners** - rejected because both required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs.
## Consequences
@@ -6,7 +6,7 @@ Status: implemented
## 问题
拉取请求工作流通过在一台更大型运行器内并发调度完整的主 Node 门禁清单来达到延迟目标。优化调度器仍不应成为自身唯一的完整性判定基准:如果其门禁清单或依赖图存在缺陷,即使优化作业保持绿灯,也可能漏掉部分工作。
拉取请求工作流将必需检查合并到专用的 Linux 和 Windows 作业中。这些作业仍不应成为唯一的完整性判定基准:如果其门禁清单或依赖图存在缺陷,即使必需聚合结果保持绿灯,也可能漏掉部分工作。
将非 Windows 作业的 1 分钟目标和 Windows 作业的 3 分钟目标写成作业超时,会引入另一种失败模式。托管运行器的启动时间和性能会波动,因此即使门禁本身正确,也可能在到达目标时间边界时被取消,来不及输出有用的诊断信息。性能目标需要根据 GitHub 时间戳衡量,而正确性验证需要给门禁留足完成时间。
@@ -14,21 +14,21 @@ Status: implemented
## 决策
[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求只运行使用更大型运行器的优化作业和兼容性作业。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux``serial / macos``serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。
[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux``serial / macos``serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。
每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci``DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。三种操作系统的作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。
master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行优化作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估非 Windows 作业的 1 分钟目标和 Windows 作业的 3 分钟目标,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
可移植的参考流程使用 GitHub 标准的 `ubuntu-latest``macos-latest``windows-2025` 标签。仍可将更高核心数的托管运行器作为未来的基准测试,但不将其设为默认选择:更大型运行器需要组织自有的标签和预配,而参考判定基准应无需仓库外部的运行器配置即可运行。日后完成这类预配,可以改变性能实验而无需改变该正确性基线
可移植的参考流程使用 GitHub 标准的 `ubuntu-latest``macos-latest``windows-2025` 标签。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用相同的可移植 Linux 和 Windows 容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行
## 曾考虑的替代方案
- **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。
- **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。
- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业有意以更长的总耗时和更多运行器用量换取简单性,快速反馈循环不需要它们
- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和 Windows 契约
- **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。
- **在大型运行器上运行串行参考流程**:不予采纳,因为该参考流程是特定组织拉取请求拓扑的可移植后备方案。快速拉取请求路径使用已预配的更大型运行器;串行 master 路径保留标准标签
- **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,必需 CI 及其独立参考流程都必须仍可运行
## 后果
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-22-evidence-based-larger-hosted-runners.md: c0fae2841f21c431d6416cd5d421929d70197abb
2026-07-22-evidence-based-larger-hosted-runners.zh.md: 51c73a8a631af4f1254c795d09585770fc4e68bb
2026-07-22-evidence-based-larger-hosted-runners.md: c292a4ea49320d684c35d2b9986549d693efb914
2026-07-22-evidence-based-larger-hosted-runners.zh.md: 5e59c787a85bd2093f0c3ceaa8290e7cd42528fa
@@ -14,11 +14,7 @@ Larger runners make it possible to pay setup once and parallelize inside the rep
The organization keeps twelve x64 larger-runner pools in the repo-restricted `dsh-larger-ci` group: Ubuntu 24.04 and Windows 2025 at 4, 8, 16, 32, 64, and 96 cores. Public IPs are disabled. Each pool has an autoscaling ceiling of 256; the ceiling does not allocate idle machines or remove the need to bound workflow demand.
Production CI uses five larger-runner executions and one standard-runner aggregator. The primary Node inventory is not sharded:
- `node 24 / complete` uses one 96-core Linux runner. One checkout, direct selection of the image's preinstalled Node 24 toolcache, pnpm- and ESLint-cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 10 independent gates; ESLint and coverage use at most 16 workers, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore both caches without saving them, so cache compression and upload do not extend the required job; the master serial reference refreshes those caches outside the pull-request critical path. An uncached exact-head trace put ESLint at 38.11 seconds and coverage at 37.10 seconds, so the small ESLint restore remains useful on the critical path. The read-only job does not persist checkout credentials.
- Node 22.19 and Node 26 use the 4- and 32-core Linux pools for their runtime compatibility smokes. Python 3.10 uses the 8-core Linux pool for the complete keyless SDK suite. These are environment contracts, not slices of the primary Node gate inventory.
- `windows node 24 / complete` uses one 32-core Windows runner. One preparation wave feeds the required package build, required production site build, and complete observational portability inventory. Required failures fail the job; observational failures are reported as non-blocking. ESLint stays single-threaded because 16 ESLint workers took 174.54 seconds, coverage uses at most 12 workers, and the outer scheduler retains 16 slots. The job restores only the small master-refreshed ESLint cache and performs a clean pnpm install instead of restoring or saving the many-file package store. All six Windows larger-runner sizes completed install and the production-site benchmark without mutating the machine-wide Developer Mode registry key, so the pull-request critical path omits that redundant step.
The pools are measurement infrastructure, not a dependency of ordinary pull requests. The [portable required-CI decision](2026-07-23-portable-required-pull-request-ci.md) runs branch-protection jobs on standard GitHub-hosted capacity; `suite=larger-runner-benchmark` compares isolated critical lanes across every provisioned size, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work.
The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture.
@@ -38,15 +34,15 @@ The same benchmark measured the required Windows build surfaces across every pro
Repository work gains little above 16 Windows cores, but the 32-core pool can start the complete outer inventory together. A [retargeted production validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2) completed the full one-box Windows inventory in 173 seconds, including coverage and snapshot replay, so Windows remains consolidated.
The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In [one exact-head production run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681), Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A [cacheless all-size trace](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155) completed the same 32-core Windows install in 27 seconds. Production therefore avoids the Windows package-store cache, uses restore-only caches on latency-critical pull-request jobs, and bounds outer concurrency so typecheck, lint, coverage, and build do not oversubscribe one host.
The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In [one exact-head candidate run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681), Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A [cacheless all-size trace](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155) completed the same 32-core Windows install in 27 seconds. A future larger-runner rollout therefore needs complete-job measurements rather than gate-only timing.
Three host effects remain part of the decision. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, which is why environment contracts use distinct larger-runner pools instead of standard capacity. The setup-node action later spent 3.68 seconds printing cached Linux environment details and 46.56 seconds doing the same on Windows after both had already found Node 24.18.0 in the hosted toolcache. The two latency-critical jobs select the newest preinstalled 24.x directory directly, verify its major, and fail loud if the image no longer carries it; compatibility jobs retain setup-node because selecting a non-default runtime is their contract. A Linux candidate also spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation.
Host setup remains part of any comparison. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, while `actions/setup-node` spent 46.56 seconds printing cached Windows environment details after finding Node in the hosted toolcache. A Linux candidate also spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation.
Inner and outer worker limits are separate controls. An [exact-head 32-worker ESLint experiment](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463) slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Production therefore retains 16 ESLint workers and admits 10 independent repository gates at once, leaving capacity for the worker pools owned by those gates without starving later independent work.
Inner and outer worker limits are separate controls. An [exact-head 32-worker ESLint experiment](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463) slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Core count therefore does not justify copying an equally large worker limit.
Linux coverage caps each project at 16 workers, while Windows keeps the 12-worker cap. The process-bound project contains exactly five suite files, so its fork count cannot reach either cap. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite: under aggregate gate contention its thread worker completed every test but intermittently missed the stdin-error callback needed for per-file function coverage. It also includes the pi-ai adapter suite after two hosted aggregate runs delayed an idle-watchdog socket-close observation past its 100-millisecond test deadline. A 32-worker all-gate run on the 96-core host slowed coverage to 44.6 seconds and made a compute-budget regression cross its one-second threshold, so production stops at 16. This preserves the suites' isolation contracts and deterministic coverage while avoiding forked execution for ordinary test files.
The process-bound coverage project contains exactly five suite files. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite and the pi-ai adapter suite because aggregate contention changed timing observations in both. These failures make deterministic coverage, not advertised cores, the upper bound on worker selection.
The workflow retains two manual measurement suites. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, and `suite=consolidated-runner-benchmark` compares whole aggregates. Complete serial Linux, macOS, and Windows references run only when `master` moves; pull requests run only the optimized jobs.
Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the portable required path, while larger-runner suites run only by manual dispatch.
## Alternatives considered
@@ -54,11 +50,11 @@ The workflow retains two manual measurement suites. `suite=larger-runner-benchma
**Keep the former gate-level shard topology as a manual reference.** A dormant second topology kept hundreds of workflow lines, selector modules, and scenario-partition behavior alive. The all-size and serial suites provide timing and completeness controls without preserving production code that no required job exercises.
**Use the 64-core pool for the complete primary aggregate.** Its sampled active time was three seconds lower than the 96-core result because hosted setup was nine seconds faster, but its repository gates were 5.72 seconds slower. Production uses 96 cores for the shorter controllable critical path; the benchmark suite retains both pools so a sustained image or pricing change can reverse that choice with evidence.
**Use the 64-core pool for the complete primary aggregate.** Its sampled active time was three seconds lower than the 96-core result because hosted setup was nine seconds faster, but its repository gates were 5.72 seconds slower. The benchmark suite retains both pools because a sustained image or pricing change can reverse the comparison.
**Keep build behind typecheck.** This orders independent compiler invocations and turns snapshot replay into a three-stage critical chain. Build output has its own success dependency, so only snapshot and publication consumers wait for it.
**Keep compatibility and Python on standard runners.** Warm standard runs can fit, but runner setup alone has crossed the non-Windows target. Distinct larger pools isolate these environment contracts from that allocation lottery.
**Make larger-runner pools the required default.** This offers lower measured latency when allocation works, but a missing entitlement or delayed organization transfer leaves required jobs queued without repository diagnostics. The portable path accepts longer runtime, and manual suites preserve the performance experiment.
**Keep required and observational Windows checks in separate jobs.** The split preserves status semantics at the workflow level but pays setup twice. `run-gates` preserves the same required versus non-blocking distinction inside one process.
@@ -66,10 +62,10 @@ The workflow retains two manual measurement suites. `suite=larger-runner-benchma
## Consequences
Primary Node CI has one job, one setup wave, one complete gate inventory, and no shard selectors. Together with two Node compatibility executions, Python, and Windows, production has five paid larger-runner executions instead of seven coarse-lane executions or 49 gate-level executions.
The benchmark topology pays one setup wave per measured aggregate and retains no shard selectors. It runs paid larger-runner executions only when manually dispatched instead of charging every pull request.
GitHub rounds each larger-runner execution up to a whole minute, so eliminating setup waves reduces billed time as well as workflow complexity. The final aggregator remains on a standard runner because it begins only after the paid jobs release capacity.
GitHub rounds each larger-runner execution up to a whole minute, so whole-aggregate measurement exposes both billed time and workflow complexity without making that cost part of branch protection.
The current targets are observed performance contracts, not cancellation deadlines. Exact-head production runs must show every non-Windows job below one minute and the consolidated Windows job below three minutes; manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement.
Performance targets are observations, not cancellation deadlines or correctness requirements. Manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement.
Production CI depends on the organization-owned runner labels in [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml). Missing or renamed pools leave jobs queued instead of falling back to standard capacity. All twelve pools remain provisioned so the manual benchmarks can re-evaluate the production size without an administrative setup cycle.
Missing or renamed organization-owned labels leave only manual benchmark jobs queued. All twelve pools remain defined so the benchmark can compare sizes after allocation recovers, while required CI follows the standard-runner fallback.
@@ -14,11 +14,7 @@ Status: implemented
组织在仅限本仓库使用的 `dsh-larger-ci` 运行器组中保留 12 个 x64 大型运行器池:Ubuntu 24.04 和 Windows 2025 各设 4、8、16、32、64、96 核规格。公网 IP 已禁用。每个池的自动扩缩容上限为 256;该上限既不会分配闲置机器,也不能免除限制工作流需求的必要性。
生产 CI 包含 5 次大型运行器执行和 1 个标准运行器聚合作业。主 Node 门禁清单不再分片:
- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、直接选择托管映像中预装的 Node 24 toolcache、恢复 pnpm 和 ESLint 缓存以及安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 10 项相互独立的门禁;ESLint 和覆盖率最多使用 16 个工作线程,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复这两项缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新这两项缓存。一次未使用缓存的分支头精确运行轨迹显示,ESLint 耗时 38.11 秒,覆盖率耗时 37.10 秒,因此在关键路径上恢复这个较小的 ESLint 缓存仍有价值。该只读作业不会持久化代码检出凭据。
- Node 22.19 和 Node 26 分别使用 4 核和 32 核 Linux 池运行各自的运行时兼容性冒烟测试。Python 3.10 使用 8 核 Linux 池运行完整的无密钥 SDK 套件。这些作业属于环境契约,并非主 Node 门禁清单的分片。
- `windows node 24 / complete` 使用一台 32 核 Windows 运行器。一轮准备工作供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。任何必需项失败都会使作业失败;观测项失败则报告为非阻塞。ESLint 保持单线程,因为 16 个 ESLint 工作线程耗时 174.54 秒;覆盖率最多使用 12 个工作线程,外层调度器则保留 16 个槽位。该作业仅恢复由 master 刷新的较小 ESLint 缓存,并在干净环境中执行 pnpm 安装,而不恢复或保存包含大量文件的包存储。全部 6 种 Windows 大型运行器规格都在未修改系统级 Developer Mode 注册表项的情况下完成了安装和生产网站基准测试,因此拉取请求关键路径省略了这个多余步骤。
这些运行器池是测量基础设施,不是普通拉取请求的依赖。依据[可移植必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),分支保护作业在 GitHub 标准托管容量上运行;`suite=larger-runner-benchmark` 比较每种已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。
原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。
@@ -38,15 +34,15 @@ Status: implemented
Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完整的外层清单同时启动。一次[重新定向的生产验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2)在 173 秒内完成了单机 Windows 完整清单,其中包括覆盖率和快照回放,因此 Windows 继续采用合并执行方式。
客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在[一次分支头精确的生产运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681)中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次[无缓存的全规格运行轨迹](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155)在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,生产环境不使用 Windows 包存储缓存,在对延迟敏感的拉取请求作业中使用只恢复不保存的缓存,并限制外层并发度,以免类型检查、lint、覆盖率和构建在同一台主机上过度争用资源
客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在[一次分支头精确的候选运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681)中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次[无缓存的全规格运行轨迹](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155)在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,未来若要启用大型运行器,需要测量完整作业,而不能只测门禁耗时
3 项主机效应仍构成这项决策的依据。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job`,因此各项环境契约使用不同的大型运行器池,而非标准容量。setup-node action 在 Linux 和 Windows 均已从托管 toolcache 找到 Node 24.18.0 后,仍分别花费 3.68 秒和 46.56 秒输出缓存的环境详情。两个延迟关键作业会直接选择最新的预装 24.x 目录并验证其主版本号;如果映像不再提供该目录,作业会明确报错并失败。兼容性作业仍使用 setup-node,因为选择非默认运行时正是它们的契约。一个 Linux 候选作业还在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定包内容并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。
任何比较都必须计入主机设置。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job``actions/setup-node` 从托管 toolcache 找到 Node 后,仍花费 46.56 秒输出缓存的 Windows 环境详情。一个 Linux 候选作业还在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定包内容并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。
内层与外层工作线程上限是相互独立的控制机制。一次[分支头精确、使用 32 个工作线程的 ESLint 实验](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463)使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,生产环境将 ESLint 工作线程上限维持在 16 个,并且同时最多运行 10 项相互独立的仓库门禁,既为这些门禁自身的工作线程池留出容量,又避免后续独立工作因资源不足而迟迟无法启动
内层与外层工作线程上限是相互独立的控制机制。一次[分支头精确、使用 32 个工作线程的 ESLint 实验](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463)使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,不能仅凭核心数照搬同等规模的工作线程上限
Linux 覆盖率把每个项目的工作线程上限设为 16 个,Windows 则保留 12 个工作线程的上限。进程约束项目恰好包含 5 个套件文件,因此它的 fork 数量不可能达到任一上限。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单还包含本地 bash 进程通路套件:在聚合门禁争用资源时,该套件的工作线程虽然完成了所有测试,却会间歇性漏记逐文件函数覆盖率所需的 stdin 错误回调。两次托管聚合运行都将空闲看门狗对套接字关闭的观测延迟到超过其 100 毫秒测试截止时间,因此这份清单还包含 pi-ai 适配器套件。在 96 核主机上使用 32 个工作线程运行全部门禁时,覆盖率耗时变慢至 44.6 秒,还使一项计算预算回归超过其 1 秒阈值,因此生产环境将工作线程数限制在 16 个以内。这样既能保留这些套件的隔离契约和覆盖率结果的确定性,又能避免以 fork 方式执行普通测试文件
进程约束的覆盖率项目恰好包含 5 个套件文件。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单包括本地 bash 进程通路套件和 pi-ai 适配器套件,因为聚合争用改变了二者的时序观测结果。这些故障表明,选择工作线程数量时,上限取决于能否得到确定的覆盖率结果,而非标称核心数
工作流保留 2 项手动测量套件。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程。只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考拉取请求只运行优化后的作业
只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考拉取请求使用可移植的必需路径,大型运行器套件仅通过手动触发运行
## 曾考虑的替代方案
@@ -54,11 +50,11 @@ Linux 覆盖率把每个项目的工作线程上限设为 16 个,Windows 则
**将原有的门禁级分片拓扑保留为手动参考。** 一套闲置的第二拓扑会让数百行工作流、选择器模块和场景分区行为继续存活。全规格和串行套件无需保留任何必需作业都不执行的生产代码,也能提供计时与完整性对照。
**使用 64 核池运行完整主聚合流程。** 由于托管设置快了 9 秒,其采样活动耗时比 96 核结果少 3 秒,但仓库门禁慢了 5.72 秒。生产环境使用 96 核来缩短可控的关键路径;基准测试套件保留两种规格,因此如果映像或定价发生持续变化,仍可根据证据反转这项选择
**使用 64 核池运行完整主聚合流程。** 由于托管设置快了 9 秒,其采样活动耗时比 96 核结果少 3 秒,但仓库门禁慢了 5.72 秒。基准测试套件保留两种规格,因映像或定价持续变化可能反转比较结果
**让构建继续等待类型检查。** 此方案会给相互独立的编译器调用排定先后顺序,并把快照回放变成 3 阶段关键链。构建输出本身有独立的成功依赖关系,因此只有快照和发布消费方需要等待它。
**让兼容性和 Python 继续使用标准运行器。** 标准运行器热运行可以达到目标,但仅运行器设置一项就曾超过非 Windows 目标。不同的大型运行器池可以让这些环境契约免受这种分配波动影响
**将大型运行器池设为必需的默认选择。** 分配成功时,该方案能缩短实测延迟,但缺少使用资格或组织转移延迟都会使必需作业持续排队,且不会产生仓库诊断信息。可移植路径接受更长的运行时间,手动套件则保留性能实验
**将必需的 Windows 检查和观测性 Windows 检查保留在不同作业中。** 这种拆分在工作流层保留状态语义,却需要支付两次设置开销。`run-gates` 在一个进程内保留了相同的必需与非阻塞区别。
@@ -66,10 +62,10 @@ Linux 覆盖率把每个项目的工作线程上限设为 16 个,Windows 则
## 后果
主 Node CI 只有 1 个作业、1 轮设置、1 份完整门禁清单,而且没有分片选择器。加上 2 次 Node 兼容性执行、Python 和 Windows,生产环境共有 5 次付费大型运行器执行,而非 7 次粗粒度通道执行或 49 次门禁级执行
基准测试拓扑对每个实测聚合流程只承担 1 轮设置开销,且不保留分片选择器。付费大型运行器仅在手动触发时执行,而不会向每个拉取请求收取这项费用
GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此消除设置轮次既能减少计费时长,也能降低工作流复杂度。最终聚合作业仍使用标准运行器,因为它只会在付费作业释放容量后启动
GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此完整聚合测量能同时呈现计费时长与工作流复杂度,而不会让这项成本进入分支保护路径
当前目标是基于观测得到的性能契约,而非取消截止时间。分支头精确的生产运行必须表明每个非 Windows 作业都低于 1 分钟,合并后的 Windows 作业低于 3 分钟;当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。
性能目标是观测结果,而非取消截止时间或正确性要求。当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。
生产 CI 依赖 [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml) 中由组织持有的运行器标签。池缺失或改名会让作业一直排队,不会回退到标准容量。全部 12 个池均保持已预配状态,因此手动基准测试无需再次经过管理配置周期,就能重新评估生产规格
组织自有标签缺失或改名时,只有手动基准作业会排队。全部 12 个池均保持已定义状态,因此分配恢复后,基准测试仍可比较各规格,而必需 CI 则使用标准运行器后备路径
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-23-browser-demo-gif-recording.md: 096edf453d6b61c4d9046b284ef67a460edf4e88
2026-07-23-browser-demo-gif-recording.zh.md: f5b8eac1c8dd57a59e9c2293ecc71511078a4896
@@ -0,0 +1,29 @@
# Agent Note: Browser demo GIF recording
Status: implemented
English | [中文](2026-07-23-browser-demo-gif-recording.zh.md)
## Problem
Browser demonstrations have been assembled with one-off capture and encoding commands. That makes timing and output size inconsistent, encourages continuous recordings that obscure the useful state changes, and can blur the boundary between a genuine server or API flow and a fixture. Combining local recording with attachment upload or pull-request editing also gives a media task unrelated remote-write authority.
## Decision
The repository provides the [`record-browser-gif`](../../../skills/record-browser-gif/SKILL.md) skill for local browser-demo artifacts. It uses the available browser-control workflow, establishes whether the requested flow is real, fixture-backed, or otherwise simulated, and captures a small storyboard only after semantically observable UI states. Frames and the output live outside the Git worktree by default.
The bundled `encode_gif.py` helper orders frames lexically, assigns explicit hold durations, uses an `ffmpeg` palette pipeline, and validates source dimensions plus the encoded frame count, dimensions, duration, and byte limit through `ffprobe`. The workflow stops after returning the verified absolute GIF path; uploading the artifact and mutating a pull request, issue, or document remain separate workflows.
## Alternatives considered
**Record continuous video and convert it afterward.** Continuous capture preserves every cursor movement and loading transition but produces larger, noisier artifacts and makes deterministic timing harder. A state storyboard better fits short feature demonstrations where the meaningful evidence is a handful of visible transitions.
**Keep an inline `ffmpeg` recipe in the skill.** Reconstructing quoting, timing manifests, palette filters, overwrite behavior, and post-encode checks in every run is error-prone. A bundled helper keeps those mechanics executable while the skill owns capture judgment.
**Include GitHub attachment and description editing.** Upload and remote mutation require separate authentication, confirmation, and recovery rules. Excluding them keeps invocation of a recording skill local and reversible.
**Use a fixture whenever it is easier to stage.** Fixtures are valid when the requested demonstration is explicitly fixture-backed, but they do not substantiate a real-server or real-API claim. The skill preserves the requested provenance and reports a missing prerequisite instead of silently changing it.
## Consequences
Recordings are small, repeatable local artifacts with explicit provenance and a clean repository boundary. The workflow gives up smooth continuous motion, depends on locally available `ffmpeg` and `ffprobe`, and requires the recorder to identify semantic capture points. The helper is exercised against a four-state browser demonstration and invalid duration input; skill shape and repository links are covered by the skill validator and documentation gates.
@@ -0,0 +1,29 @@
# Agent Note: 浏览器演示 GIF 录制
Status: implemented
[English](2026-07-23-browser-demo-gif-recording.md) | 中文
## 问题
浏览器演示一直通过一次性的截取与编码命令制作。这会导致播放节奏和输出大小不一致,容易让录制者选择连续录制,反而掩盖有用的状态变化,还可能模糊真实服务器或 API 流程与 fixture(测试前置数据)之间的界限。将本地录制与附件上传或 PR(Pull Request)编辑合并在同一任务中,还会让本应仅处理媒体的任务获得无关的远程写入权限。
## 决策
仓库提供 [`record-browser-gif`](../../../skills/record-browser-gif/SKILL.md) skill(技能),用于生成本地浏览器演示产物。该 skill 使用当前可用的浏览器控制工作流,先确认请求的流程是真实流程、由 fixture 支撑,还是采用其他模拟方式,再仅在 UI 达到语义上可观察的状态后截取一组精简的分镜帧。帧文件与输出产物默认存放在 Git worktree 之外。
随附的 `encode_gif.py` 辅助脚本按词法顺序排列各帧,为每帧设置明确的停留时长,通过 `ffmpeg` 调色板流水线编码,并借助 `ffprobe` 校验源图像尺寸以及编码结果的帧数、尺寸、时长和字节上限。工作流在返回已验证的 GIF 绝对路径后即结束;上传产物以及修改 PR、issue 或文档仍属于独立的工作流。
## 曾考虑的替代方案
**连续录制视频后再转换。**连续录制能保留每一次光标移动和加载过渡,但会产生体积更大、干扰更多的产物,也更难保持确定的播放时序。状态分镜更适合简短的功能演示,因为有意义的证据只是少数几个可见的状态变化。
**在 skill 中保留内联 `ffmpeg` 配方。**每次运行都重新组装引号转义、时序清单、调色板过滤器、覆盖行为和编码后检查,容易出错。随附的辅助脚本使这些机制保持可执行,skill 则负责判断何时截取画面。
**纳入 GitHub 附件上传与描述编辑。**上传和远程修改需要各自独立的身份认证、确认与恢复规则。将它们排除在外,可以使录制 skill 的调用保持本地且可撤销。
**每当 fixture 更容易布置时就使用它。**当请求明确要求由 fixture 支撑演示时,使用 fixture 是有效的;但它无法为真实服务器或真实 API 的声明提供证据。该 skill 会保持请求指定的演示来源,并在缺少先决条件时报告问题,不会擅自更改来源。
## 后果
录制结果成为体积小、可重复生成的本地产物,明确标注演示来源,并与仓库保持清晰边界。该工作流放弃了流畅的连续动态效果,依赖本机提供的 `ffmpeg``ffprobe`,并要求录制者识别具有语义意义的截取时点。测试使用四状态浏览器演示与无效时长输入检验辅助脚本;skill 的结构及仓库链接由 skill 校验器和文档门禁覆盖。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-23-portable-required-pull-request-ci.md: a430d43f7cb3dd4df987d35f3a49d130c397f8e3
2026-07-23-portable-required-pull-request-ci.zh.md: cbd5d150056f77e52105f56c70a1ead74f052f59
@@ -0,0 +1,35 @@
# Agent Note: Portable required pull-request CI
Status: implemented
English | [中文](2026-07-23-portable-required-pull-request-ci.zh.md)
## Problem
Required pull-request jobs assigned to organization-owned runner labels remain queued when GitHub cannot allocate those pools. The workflow is valid and standard GitHub-hosted jobs can still pass, but `all checks passed` never starts and an otherwise healthy pull request cannot satisfy branch protection.
Billing health, a runner definition's `Ready` state, and a large autoscaling ceiling do not prove that a named pool can receive a job. Required correctness checks need a portable execution path that does not depend on repository-external runner provisioning.
## Decision
[CI](../../../../.github/workflows/ci.yml) runs every required pull-request job on GitHub's standard `ubuntu-latest` or `windows-2025` capacity. The primary Node and Windows jobs keep their complete consolidated inventories, while top-level gates, coverage, ESLint, publint, and snapshot replay use one worker on the smaller hosts. Node versions are selected through `actions/setup-node`, and the Windows job enables Developer Mode before installing the symlinked workspace.
The `node 24 / complete`, Node compatibility, Python SDK, and `windows node 24 / complete` jobs remain dependencies of `all checks passed`; no gate is removed or made observational to recover availability. Branch protection continues to require `e2e` and `all checks passed`.
The two manual larger-runner suites and all twelve organization-owned labels remain available for measurement. They do not participate in ordinary pull requests. The [larger-runner measurements](2026-07-22-evidence-based-larger-hosted-runners.md) remain evidence for future performance work, while the [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent master-push completeness check.
## Alternatives considered
**Wait for organization-runner allocation to recover.** A queue with no assigned runner emits no repository diagnostic and can block every pull request indefinitely, so an external recovery is not a correctness path.
**Use only the smallest organization-owned pools.** Every named pool crosses the same organization allocation boundary; reducing core count does not remove the dependency that caused the queue.
**Skip or demote checks while capacity is unavailable.** This would make the status green by dropping evidence rather than by running the repository's required contracts.
**Keep larger-host worker limits on standard runners.** Concurrent full-repository gates and their inner worker pools can oversubscribe the smaller memory and CPU allocation, turning an availability repair into contention failures.
## Consequences
Ordinary pull requests can acquire runners without organization-specific configuration, and a live exact-head run proves the same commands that branch protection consumes. The trade-off is longer elapsed time and more rounded standard-runner minutes than the measured larger-runner topology.
Manual larger-runner benchmarks can remain queued without blocking pull requests. Restoring larger runners to the required path needs a separate evidence-based decision after exact-head jobs receive nonzero runner IDs and complete reliably; changing a definition's status alone is insufficient.
@@ -0,0 +1,35 @@
# Agent Note: 可移植的拉取请求必需 CI
Status: implemented
[English](2026-07-23-portable-required-pull-request-ci.md) | 中文
## 问题
分配到组织自有运行器标签的拉取请求必需作业,在 GitHub 无法为这些池分配运行器时会持续排队。工作流本身有效,GitHub 标准托管作业仍能通过,但 `all checks passed` 始终无法启动,原本健康的拉取请求因此无法满足分支保护要求。
账单状态正常、运行器定义处于 `Ready` 状态以及较高的自动扩缩容上限,都不能证明指定的运行器池可以接收作业。必需的正确性检查需要一条可移植的执行路径,且该路径不能依赖仓库外部的运行器预配。
## 决策
[CI](../../../../.github/workflows/ci.yml) 在 GitHub 标准的 `ubuntu-latest``windows-2025` 容量上运行每项拉取请求必需作业。主 Node 作业和 Windows 作业保留各自完整的合并清单,而顶层门禁、覆盖率、ESLint、publint 和快照回放在这些较小的主机上均使用 1 个工作线程。Node 版本通过 `actions/setup-node` 选择;Windows 作业会在安装采用符号链接的工作区前启用开发人员模式。
`node 24 / complete`、Node 兼容性、Python SDK 和 `windows node 24 / complete` 作业继续作为 `all checks passed` 的依赖项;为恢复可用性,不会移除任何门禁,也不会将其降为观测性检查。分支保护继续要求 `e2e``all checks passed`
两项手动大型运行器套件和全部 12 个组织自有标签继续用于测量,但不参与普通拉取请求。[大型运行器测量结果](2026-07-22-evidence-based-larger-hosted-runners.md)继续作为后续性能工作的证据,[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则继续作为 master 推送时独立的完整性检查。
## 曾考虑的替代方案
**等待组织运行器恢复分配。** 未分配运行器的队列不会产生仓库诊断信息,而且可能无限期阻塞每个拉取请求,因此依赖外部恢复不能构成正确性路径。
**仅使用最小的组织自有运行器池。** 每个指定的运行器池都需要经过相同的组织分配边界;减少核心数不能消除导致作业排队的依赖。
**在容量不可用时跳过检查或降低其级别。** 这种方式通过丢弃证据而非执行仓库的必需契约来使状态变绿。
**在标准运行器上保留大型主机的工作线程上限。** 完整仓库门禁及其内层工作线程池并发运行时,可能超出较小主机的内存和 CPU 配额,使可用性修复变成资源争用故障。
## 后果
普通拉取请求无需组织专有配置即可获得运行器,一次实际的分支头精确运行能够证明分支保护使用的同一组命令。代价是,与实测的大型运行器拓扑相比,总耗时更长,而且按整分钟计费的标准运行器用量更多。
手动大型运行器基准测试可以继续排队,而不会阻塞拉取请求。要将大型运行器恢复为必需路径,需要在分支头精确作业获得非零运行器 ID 并可靠完成后,另行作出基于证据的决策;仅改变运行器定义的状态还不够。
@@ -0,0 +1,53 @@
---
name: record-browser-gif
description: Record browser or Web UI interaction demos as optimized local GIFs using the available built-in browser, state-based frame capture, and deterministic encoding. Use when Codex is asked to make, record, or generate a GIF that demonstrates a browser workflow, including real-server or real-API behavior. Stop after returning the verified local artifact; do not upload it or edit a pull request.
---
# Record Browser GIF
Produce a short, truthful UI demonstration as a local GIF. Use the browser-control skill for interaction and the bundled encoder for repeatable timing, dimensions, and size.
## Keep the boundary explicit
- Produce frame images and one local `.gif` artifact only.
- Never upload the artifact, post a comment, or change a pull request, issue, or document under this skill. Hand those actions to a separate workflow if the user requests them.
- Preserve the requested provenance. A real-server or real-API demo must not use fixture queries, mock transports, synthetic event injection, or test-only hooks. If credentials or the server are unavailable, report that limitation instead of substituting a fixture.
- Never read or expose credential values. Use the application's normal configuration path and a benign demonstration prompt.
## Record the flow
1. Invoke the available browser-control skill and follow its setup, interaction, and cleanup instructions. Use the user's existing Chrome state only when requested or required.
2. Resolve the evidence boundary before recording: identify the exact origin, whether the app is built or in development, the transport, and any fixture or mock mode. Record only claims that the observed setup supports.
3. Choose three to six states that tell one story, such as initial, typed, submitted, and completed. Prefer semantic state changes over continuous capture; omit loading churn that does not help the viewer.
4. Keep one viewport and crop for every frame. Store frames in an absolute artifact directory outside the Git worktree unless the user requests another location, and name them lexically: `00-initial.png`, `01-typed.png`, and so on.
5. Before each screenshot, wait for a concrete UI condition such as a unique label, enabled control, changed document title, or completed response. Do not use a fixed delay as proof that the application reached the state.
6. Capture no secrets, personal data, unrelated tabs, or transient notifications. Stop any unnecessarily long real-API run after the demonstrated state is visible.
Use the browser's own screenshot API. When it returns image bytes, save those bytes directly; the encoder detects image content independently of the filename extension.
## Encode the GIF
Require `python3`, `ffmpeg`, and `ffprobe`. If either media binary is missing, report the dependency instead of installing software without authorization.
Set `GIF_SKILL_DIR` to this skill's absolute directory, then encode the lexically ordered frames:
```sh
python3 "$GIF_SKILL_DIR/scripts/encode_gif.py" \
/absolute/path/to/frames \
/absolute/path/to/demo.gif \
--durations 1.5,1.5,1.5,3.5 \
--fps 10 \
--max-width 1200 \
--colors 128
```
One duration applies to every frame; otherwise provide one comma-separated positive duration per frame. The encoder rejects fewer than two frames, mismatched dimensions or durations, invalid limits, accidental overwrite, unexpected duration, and output above `--max-bytes`.
For a large artifact, reduce `--max-width` first, then `--colors` or `--fps`; retain readable text and the final state long enough to inspect. Use `--force` only after resolving the exact output path.
## Verify and deliver
1. Read the encoder's JSON summary and confirm the output path, source and encoded frame counts, dimensions, duration, and byte size.
2. Inspect the first and final source frames and the resulting GIF. Confirm that the transition is legible, the last state is held long enough, and no sensitive content appears.
3. If capture occurred near a repository, run `git status --short` and confirm the artifact did not dirty the worktree.
4. Return the absolute GIF path, render it when the client supports local media, and state whether the recording used a real API, fixture, or another transport. Stop without uploading it or editing remote content.
@@ -0,0 +1,4 @@
interface:
display_name: "Record Browser GIF"
short_description: "Record and optimize local browser demo GIFs"
default_prompt: "Use $record-browser-gif to record this browser flow as a verified local GIF."
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env python3
"""Encode lexically ordered browser screenshots into a verified GIF."""
from __future__ import annotations
import argparse
import json
import math
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import NoReturn
DEFAULT_MAX_BYTES = 5 * 1024 * 1024
def fail(message: str) -> NoReturn:
"""Exit with a concise user-correctable error."""
raise SystemExit(f"error: {message}")
def positive_float(value: str) -> float:
"""Parse one finite positive command-line number."""
try:
parsed = float(value)
except ValueError:
fail(f"expected a number, got {value!r}")
if not math.isfinite(parsed) or parsed <= 0:
fail(f"expected a positive finite number, got {value!r}")
return parsed
def positive_int(value: str) -> int:
"""Parse one positive command-line integer."""
try:
parsed = int(value)
except ValueError:
fail(f"expected an integer, got {value!r}")
if parsed <= 0:
fail(f"expected a positive integer, got {value!r}")
return parsed
def parse_durations(value: str, frame_count: int) -> list[float]:
"""Expand one hold duration or validate one duration per source frame."""
parts = [part.strip() for part in value.split(",")]
if not parts or any(not part for part in parts):
fail("--durations must be a number or a comma-separated list of numbers")
durations = [positive_float(part) for part in parts]
if len(durations) == 1:
return durations * frame_count
if len(durations) != frame_count:
fail(f"--durations supplied {len(durations)} values for {frame_count} frames")
return durations
def require_binary(name: str) -> str:
"""Resolve a required media binary or fail without attempting installation."""
path = shutil.which(name)
if path is None:
fail(f"required binary {name!r} is not available on PATH")
return path
def run_json(command: list[str]) -> dict[str, object]:
"""Run a media probe and parse its JSON object."""
try:
completed = subprocess.run(command, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as error:
detail = error.stderr.strip() or error.stdout.strip() or str(error)
fail(detail)
try:
value = json.loads(completed.stdout)
except json.JSONDecodeError as error:
fail(f"media probe returned invalid JSON: {error}")
if not isinstance(value, dict):
fail("media probe returned a non-object JSON value")
return value
def probe_stream(ffprobe: str, path: Path) -> dict[str, object]:
"""Read the first video stream's dimensions and timing metadata."""
result = run_json(
[
ffprobe,
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,nb_frames,duration,r_frame_rate",
"-of",
"json",
str(path),
]
)
streams = result.get("streams")
if not isinstance(streams, list) or len(streams) != 1 or not isinstance(streams[0], dict):
fail(f"expected one video stream in {path}")
return streams[0]
def stream_int(stream: dict[str, object], key: str, path: Path) -> int:
"""Read a positive integer stream field."""
try:
value = int(stream[key])
except (KeyError, TypeError, ValueError):
fail(f"missing integer {key!r} in media probe for {path}")
if value <= 0:
fail(f"non-positive {key!r} in media probe for {path}")
return value
def ffconcat_quote(path: Path) -> str:
"""Quote an ffconcat path while preserving literal backslashes."""
value = str(path)
if "\n" in value or "\r" in value:
fail(f"frame path contains a newline: {path}")
return "'" + value.replace("'", "'\\''") + "'"
def write_concat_manifest(path: Path, frames: list[Path], durations: list[float]) -> None:
"""Write an ffconcat manifest that materializes the final frame's hold."""
lines = ["ffconcat version 1.0"]
for frame, duration in zip(frames, durations):
lines.append(f"file {ffconcat_quote(frame)}")
lines.append(f"duration {duration:.6f}")
lines.append(f"file {ffconcat_quote(frames[-1])}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def build_parser() -> argparse.ArgumentParser:
"""Build the command-line contract."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("frames", type=Path, help="directory containing lexically ordered frames")
parser.add_argument("output", type=Path, help="output .gif path")
parser.add_argument("--pattern", default="*.png", help="frame glob within the input directory")
parser.add_argument(
"--durations",
default="2",
help="one hold duration or one comma-separated value per frame",
)
parser.add_argument("--fps", type=positive_int, default=10, help="encoded frames per second")
parser.add_argument(
"--max-width",
type=positive_int,
default=1200,
help="maximum output width",
)
parser.add_argument(
"--colors",
type=positive_int,
default=128,
help="palette colors, from 4 through 256",
)
parser.add_argument(
"--max-bytes",
type=positive_int,
default=DEFAULT_MAX_BYTES,
help="maximum output size",
)
parser.add_argument("--force", action="store_true", help="replace an existing output file")
return parser
def main() -> None:
"""Validate inputs, encode the GIF, verify it, and print a JSON summary."""
args = build_parser().parse_args()
frame_dir = args.frames.resolve()
output = args.output.resolve()
if not frame_dir.is_dir():
fail(f"frame directory does not exist: {frame_dir}")
if output.suffix.lower() != ".gif":
fail(f"output must end in .gif: {output}")
if output.exists() and not args.force:
fail(f"output already exists (pass --force to replace it): {output}")
if not 4 <= args.colors <= 256:
fail("--colors must be between 4 and 256")
if args.fps > 30:
fail("--fps must not exceed 30")
frames = sorted(path.resolve() for path in frame_dir.glob(args.pattern) if path.is_file())
if len(frames) < 2:
fail(f"expected at least two frames matching {args.pattern!r} in {frame_dir}")
if output in frames:
fail("output path must not match an input frame")
durations = parse_durations(args.durations, len(frames))
expected_duration = sum(durations)
ffmpeg = require_binary("ffmpeg")
ffprobe = require_binary("ffprobe")
dimensions = {
(stream_int(stream, "width", frame), stream_int(stream, "height", frame))
for frame in frames
for stream in [probe_stream(ffprobe, frame)]
}
if len(dimensions) != 1:
fail(f"all frames must have identical dimensions, got {sorted(dimensions)}")
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="record-browser-gif-") as temporary:
manifest = Path(temporary) / "frames.ffconcat"
write_concat_manifest(manifest, frames, durations)
scale = f"scale='min({args.max_width},iw)':-2:flags=lanczos"
palette = f"palettegen=max_colors={args.colors}:stats_mode=full"
filters = (
f"fps={args.fps},{scale},split[base][palette_input];"
f"[palette_input]{palette}[palette];"
"[base][palette]paletteuse=dither=bayer:bayer_scale=3:diff_mode=rectangle"
)
command = [
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-f",
"concat",
"-safe",
"0",
"-i",
str(manifest),
"-vf",
filters,
"-loop",
"0",
"-t",
f"{expected_duration:.6f}",
"-y" if args.force else "-n",
str(output),
]
try:
subprocess.run(command, check=True)
except subprocess.CalledProcessError as error:
fail(f"ffmpeg failed with exit code {error.returncode}")
stream = probe_stream(ffprobe, output)
width = stream_int(stream, "width", output)
height = stream_int(stream, "height", output)
encoded_frames = stream_int(stream, "nb_frames", output)
try:
actual_duration = float(stream["duration"])
except (KeyError, TypeError, ValueError):
fail(f"missing duration in media probe for {output}")
tolerance = max(0.2, 2 / args.fps)
if abs(actual_duration - expected_duration) > tolerance:
fail(f"expected about {expected_duration:.3f}s, encoded {actual_duration:.3f}s")
if width > args.max_width:
fail(f"expected width at most {args.max_width}, encoded {width}")
if encoded_frames < 2:
fail(f"expected an animated GIF, encoded {encoded_frames} frame")
byte_size = output.stat().st_size
if byte_size > args.max_bytes:
fail(f"output is {byte_size} bytes, above --max-bytes {args.max_bytes}")
print(
json.dumps(
{
"output": str(output),
"sourceFrames": len(frames),
"encodedFrames": encoded_frames,
"width": width,
"height": height,
"durationSeconds": actual_duration,
"fps": args.fps,
"bytes": byte_size,
},
indent=2,
sort_keys=True,
)
)
if __name__ == "__main__":
main()
+28 -42
View File
@@ -27,12 +27,11 @@ env:
jobs:
# One large runner pays hosted setup once, then the repository scheduler
# overlaps the complete unsharded primary Node inventory. Build starts eagerly;
# only consumers of emitted output wait for it.
# One enterprise runner pays setup once, then executes the complete
# unsharded primary Node inventory with repository-level concurrency.
node-24:
if: github.event_name == 'pull_request'
runs-on: dsh-ubuntu-24-04-96core
runs-on: dsh-enterprise-ubuntu-24-04-32core-test
name: node 24 / complete
env:
DSH_COVERAGE_MAX_WORKERS: '16'
@@ -62,16 +61,12 @@ jobs:
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
- name: Select preinstalled Node, install dependencies, and prepare bubblewrap
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
- name: Enable corepack, install dependencies, and prepare bubblewrap
run: |
node_root="$(printf '%s\n' "$RUNNER_TOOL_CACHE"/node/"${PRIMARY_NODE_VERSION}".*/x64 | sort -V | tail -n 1)"
if [[ ! -d "$node_root" ]]; then
echo "preinstalled Node ${PRIMARY_NODE_VERSION}.x not found in $RUNNER_TOOL_CACHE" >&2
exit 1
fi
echo "$node_root/bin" >> "$GITHUB_PATH"
export PATH="$node_root/bin:$PATH"
[[ "$(node --version)" == "v${PRIMARY_NODE_VERSION}."* ]]
corepack enable
pnpm install --frozen-lockfile &
install_pid=$!
@@ -90,8 +85,7 @@ jobs:
node-compat:
if: github.event_name == 'pull_request'
# Distinct larger-runner pools avoid both standard-runner setup outliers and
# delayed allocation when independent environment contracts share one pool.
# Each compatibility contract receives an independent standard hosted job.
runs-on: ${{ matrix.runner }}
name: ${{ matrix.name }}
env:
@@ -103,12 +97,12 @@ jobs:
include:
- node: '22.19'
name: node 22.19
runner: dsh-ubuntu-24-04-4core
gate_concurrency: '2'
runner: ubuntu-latest
gate_concurrency: '1'
- node: 26
name: node 26
runner: dsh-ubuntu-24-04-32core
gate_concurrency: '2'
runner: ubuntu-latest
gate_concurrency: '1'
steps:
- uses: actions/checkout@v6
@@ -137,7 +131,7 @@ jobs:
python-sdk:
if: github.event_name == 'pull_request'
runs-on: dsh-ubuntu-24-04-8core
runs-on: ubuntu-latest
name: python 3.10 / keyless SDK
steps:
- uses: actions/checkout@v6
@@ -158,11 +152,9 @@ jobs:
# from observational gates without allowing them to fail the required job.
windows:
if: github.event_name == 'pull_request'
runs-on: dsh-windows-2025-32core
runs-on: dsh-enterprise-windows-2025-32core-test
name: windows node 24 / complete
env:
# Keep ESLint itself single-threaded: 16 ESLint workers took 174 seconds on
# this image. The outer scheduler still overlaps lint with the other gates.
DSH_COVERAGE_MAX_WORKERS: '12'
DSH_ESLINT_CACHE: '1'
DSH_GATE_CONCURRENCY: '16'
@@ -177,27 +169,21 @@ jobs:
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
# Extracting the many-file pnpm store cache is slower on this image than
# a clean parallel install, and saving it adds more latency after gates.
- name: Select preinstalled Node and install (immutable)
- name: Enable Developer Mode (symlink support)
shell: pwsh
run: >-
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
# Extracting the many-file pnpm store cache is slower than a clean install,
# and saving it adds more latency after gates.
- name: Enable corepack and install (immutable)
shell: pwsh
run: |
$nodeRoot = Get-ChildItem -Path "$env:RUNNER_TOOL_CACHE\node" -Directory |
Where-Object { $_.Name -like "$env:PRIMARY_NODE_VERSION.*" } |
Sort-Object { [version]$_.Name } |
Select-Object -Last 1
if ($null -eq $nodeRoot) {
throw "preinstalled Node $env:PRIMARY_NODE_VERSION.x not found in $env:RUNNER_TOOL_CACHE"
}
$nodeBin = Join-Path $nodeRoot.FullName 'x64'
if (-not (Test-Path $nodeBin -PathType Container)) {
throw "preinstalled Node x64 directory not found at $nodeBin"
}
Add-Content -Path $env:GITHUB_PATH -Value $nodeBin
$env:PATH = "$nodeBin;$env:PATH"
if ((node --version) -notlike "v$env:PRIMARY_NODE_VERSION.*") {
throw "selected unexpected Node version $(node --version)"
}
corepack enable
pnpm install --frozen-lockfile
+3 -1
View File
@@ -1,6 +1,6 @@
# `@deepseek-ai/dsh`
The `dsh` command-line entry, following the `apps/` assembly tier proposed by the `dsh web` PR (#443): `apps/*` are product assemblies over `packages/*` libraries. This branch ships one surface — plain `dsh [config.yml]` boots the interactive TUI coding agent — and reserves the `web` and `-p`/`--prompt` subcommands for that PR so the dispatch merges as a union.
The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh [config.yml]` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI.
The TUI surface:
@@ -10,6 +10,8 @@ The TUI surface:
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
The Web surface treats its invoking directory as the default project and loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget.
## Install (developer machine)
Symlink the source-running launcher onto your PATH; it resolves the checkout through its own real path, so code changes apply on the next launch with no build step:
+6 -1
View File
@@ -78,7 +78,12 @@ export async function runHeadless(argv: string[]): Promise<void> {
}
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
const host = await startHost({ boot: { persistenceRoot: './.sessions' } })
const host = await startHost({
boot: {
persistenceRoot: './.sessions',
workspaceContext: false,
},
})
const api = new InProcessApiClient(host.handler)
const created = await unwrap(await api.sessions.create({}), () => host.dispose())
+6 -1
View File
@@ -36,7 +36,12 @@ export async function runWeb(argv: string[]): Promise<void> {
}
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
const host = await startHost({ boot: { persistenceRoot: './.sessions' } })
const host = await startHost({
boot: {
persistenceRoot: './.sessions',
workspaceContext: { maxBytes: 65_536 },
},
})
// Web UI plugin chain: in-memory Loader tree over the eight UI packages,
// then the registry that feeds __DSH_BOOT__ and /plugins/<id>/client.js.
+61 -10
View File
@@ -1,6 +1,6 @@
// Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins
// registry surface + __DSH_BOOT__ injection + built shell dist in a real
// chromium. First describe: manifest injection + fail-loud half. Second
// chromium. First describe: manifest injection + static serving. Second
// describe: the settled success pass — all nine REAL tsdown bundles load
// through the DI chain in ?fixture mode, the three-column frame appears in
// one flip, and the resident question completes through the real UI stack.
@@ -80,15 +80,6 @@ describe('web boot chain (keyless, real carrier)', () => {
expect(await res.text()).toContain('window.DSHClientProxy.loadPlugin')
})
it('boots to the loading page and fail-louds the absent plugin', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-boot-fail-loud'))
await page.waitForSelector('text=HARNESS', { timeout: 10_000 })
await page.waitForSelector('text=Failed to load plugins', { timeout: 10_000 })
await page.waitForSelector('text=@probe/absent', { timeout: 2000 })
// The real UI must not have flipped in: the gate opens only on settled().
expect(await page.locator('[class*="frame"]').count()).toBe(0)
})
it('applies the token sheets before any plugin CSS', async () => {
const family = await page.evaluate(() => getComputedStyle(document.body).getPropertyValue('--dsw-font-family'))
expect(family.trim().length).toBeGreaterThan(0)
@@ -145,6 +136,66 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', (
const owners = await page.evaluate(() =>
[...document.querySelectorAll('style[data-plugin]')].map(s => (s as HTMLElement).dataset['plugin']))
expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout')
expect(owners).toContain('@deepseek-ai/dsh-client-ui-sidebar')
})
it('collapsed sidebar animates to a 56px rail with the four controls', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-boot-collapsed-rail'))
const frame = page.locator('[class*="frame"]')
const firstTrack = async (): Promise<string> => (await frame.evaluate(
el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]!
// The tracks transition on the deepsuite curve; assert the animated
// settle rather than an instant jump.
const settledTrack = async (px: string): Promise<void> => {
await expect.poll(firstTrack, { timeout: 2000 }).toBe(px)
}
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
// Mid-collapse the wide chrome is still mounted, fading — not swapped out.
expect(await page.locator('text=HARNESS').count()).toBe(1)
await settledTrack('56px')
await expect.poll(() => page.locator('text=HARNESS').count(), { timeout: 2000 }).toBe(0)
for (const name of ['Expand sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) {
await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true)
}
await page.getByRole('button', { name: 'Expand sidebar' }).click()
await settledTrack('300px')
await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true)
// Rail search: collapse again, the search control expands and lands in the box.
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
await settledTrack('56px')
await page.getByRole('button', { name: 'Search sessions' }).click()
await settledTrack('300px')
const focused = await page.evaluate(() =>
(document.activeElement as HTMLInputElement | null)?.placeholder ?? '')
expect(focused).toContain('Search')
})
it('renders file tool rows and expands fixture reasoning from either click target', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-think-disclosure'))
await page.locator('[role="treeitem"]').first().click()
await page.locator('[role="treeitem"][aria-selected]').first().click()
const thinkRoot = page.locator('[data-variant="think"]').first()
const think = thinkRoot.getByRole('button')
await think.waitFor({ state: 'visible', timeout: 10_000 })
expect(await think.getAttribute('aria-expanded')).toBe('false')
await thinkRoot.getByText(/^思考过程 .*reasoning 内容。$/).click()
expect(await think.getAttribute('aria-expanded')).toBe('true')
expect(await thinkRoot.locator(':scope > div').count()).toBe(2)
await think.getByText('Think', { exact: true }).click()
expect(await think.getAttribute('aria-expanded')).toBe('false')
const editRoot = page.locator('[data-variant="edit"]').first()
await editRoot.waitFor({ state: 'visible', timeout: 10_000 })
expect(await editRoot.getByText('Edit', { exact: true }).count()).toBe(1)
expect(await editRoot.getByText('notes/demo.txt', { exact: true }).count()).toBe(1)
const writeRoot = page.locator('[data-variant="write"]').first()
await writeRoot.waitFor({ state: 'visible', timeout: 10_000 })
expect(await writeRoot.getByText('Write', { exact: true }).count()).toBe(1)
expect(await writeRoot.getByText('notes/new-demo.txt', { exact: true }).count()).toBe(1)
})
it('renders and completes the resident question through the composer slot', async () => {
+106 -1
View File
@@ -15,7 +15,8 @@
// and theme after, reload recovery last. Tests run sequentially in-file.
import type { ChildProcess } from 'node:child_process'
import { spawn } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:http'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -57,6 +58,25 @@ function waitForReadyLine(child: ChildProcess): Promise<string> {
})
}
async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promise<T> {
const response = await fetch(`${baseUrl}/api/${method}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request',
rpcId: `smoke-${method}`,
method,
payload,
}),
})
if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`)
const body = await response.json() as {
result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
}
if (!body.result.ok) throw new Error(`${method} failed: ${body.result.error.code}: ${body.result.error.message}`)
return body.result.value
}
/** W5 screenshot: evidence for the figma comparison, not a failure artifact. */
async function screen(page: Page, name: string): Promise<void> {
await page.screenshot({ path: join(REPO_ROOT, '.artifacts', `w5-${name}.png`) })
@@ -116,6 +136,91 @@ describe('dsh web keyless CLI smoke', () => {
rmSync(sessionsDir, { recursive: true, force: true })
}
})
it('injects the invoking workspace AGENTS.md into the provider request', async () => {
requireDist()
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-'))
mkdirSync(join(workspace, '.git'))
writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n')
let resolveProviderRequest!: (request: { messages?: { role?: string; content?: string }[] }) => void
const providerRequest = new Promise<{ messages?: { role?: string; content?: string }[] }>((resolve) => {
resolveProviderRequest = resolve
})
const provider = createServer((request, response) => {
let body = ''
request.setEncoding('utf8')
request.on('data', (chunk: string) => { body += chunk })
request.on('end', () => {
resolveProviderRequest(JSON.parse(body) as { messages?: { role?: string; content?: string }[] })
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end([
'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
'data: {"choices":[{"delta":{"content":"done"}}]}',
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'data: [DONE]',
'',
].join('\n\n'))
})
})
await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
const address = provider.address()
if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port')
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
const child = spawn(
process.execPath,
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
{
cwd: workspace,
env: {
...process.env,
DEEPSEEK_API_KEY: 'keyless-web-workspace',
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
DSH_HOME: join(workspace, '.dsh'),
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
},
stdio: ['ignore', 'pipe', 'pipe'],
},
)
try {
const baseUrl = await waitForReadyLine(child)
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
sessionId: created.sessionId,
mode: 'queue',
content: [{ type: 'text', text: 'go' }],
})
const captured = await Promise.race([
providerRequest,
new Promise<never>((_resolve, reject) => {
setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref()
}),
])
const workspaceMessage = captured.messages?.find(message =>
message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
expect(workspaceMessage).toMatchInlineSnapshot(`
{
"content": "<system-reminder>
The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.
Instructions from: AGENTS.md
web-workspace-context-probe
</system-reminder>",
"role": "user",
}
`)
} finally {
const closed = child.exitCode === null
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
: Promise.resolve()
if (child.exitCode === null) child.kill('SIGTERM')
await closed
await new Promise<void>(resolveClose => provider.close(() => { resolveClose() }))
rmSync(workspace, { recursive: true, force: true })
}
})
})
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => {
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
architecture.md: 6ff2aa1ad4ca2ef051322f9d95631fe626d26e84
architecture.zh.md: b4b26efec16d85f1fb26589c5c9bffbb35e39564
architecture.md: 46b103ec788adbf7673e8b75643c71191318b42f
architecture.zh.md: 2684fe745fe8afd9ebf79f047dd9798ff432e506
+1 -1
View File
@@ -185,7 +185,7 @@ New behavior attaches to a documented extension point; a loop change updates thi
| Confine spawned processes | a `ctx.sandbox` backend; consumers wrap their argv before spawning |
| Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stop` is the serial terminal stop |
| Add a session-stable prefix outside history | compose `agent/session-prefix`; the request header logs it |
| Add UI or editor integration | drive `ctx.agents` and render from `session/event` |
| Add UI or editor integration | drive `ctx.agents` and render from `session/event`; terminal-only overlays use `ctx.tui` |
| Add durable session state | add a `SessionEventMap` member and render/replay from the log |
| Add asynchronous session-title generation | register the sole provider on `ctx.sessionTitle` |
| Manage a same-session objective | use `ctx.goals`; continue through `Agent` and `agent/*` |
+1 -1
View File
@@ -185,7 +185,7 @@ forever:
| 限制生成的进程 | 使用 `ctx.sandbox` 后端;消费方在生成进程前包装 argv |
| 拦截请求、工具或轮次 | 使用相应的 `agent/*``tools/*` 事件;`agent/turn-stop` 是串行终止判定点 |
| 添加历史记录之外的会话稳定前缀 | 组合 `agent/session-prefix`;请求头会记录该前缀 |
| 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染 |
| 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染;仅终端可用的浮层使用 `ctx.tui` |
| 添加持久会话状态 | 添加一个 `SessionEventMap` 成员,并从日志渲染和回放 |
| 添加异步会话标题生成 | 在 `ctx.sessionTitle` 上注册唯一提供方 |
| 管理同会话目标 | 使用 `ctx.goals`;通过 `Agent``agent/*` 续跑 |
+3
View File
@@ -61,6 +61,7 @@ flowchart LR
svc_planMode["ctx.planMode<br/>Plan collaboration state"]
pkg_commands["commands"]
svc_commands["ctx.commands<br/>Human command registry"]
svc_tui["ctx.tui<br/>Mounted-terminal interaction service"]
pkg_skill["skill"]
svc_skills["ctx.skills<br/>Skill provider registry"]
pkg_skill_local["skill-local"]
@@ -172,6 +173,7 @@ flowchart LR
pkg_token_meter --> svc_tokenMeter
pkg_tool_bash --> svc_bashEnv
pkg_tools --> svc_tools
pkg_tui --> svc_tui
pkg_tui --> svc_userInteraction
pkg_user_interaction --> svc_userInteraction
pkg_web --> svc_web
@@ -277,6 +279,7 @@ flowchart LR
| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
| `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | [`acp`](../packages/ui/acp) | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. |
| `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Plugins register direct human commands; TUI and ACP consume the same effective per-agent catalog without sending invocations to the model. |
| `ctx.tui` | `bundle` | [`tui`](../packages/ui/tui) | - | - | - | One TUI front door provides a FIFO overlay host; injected plugins receive caller-fiber ownership without access to pi-tui or terminal lifecycle state. |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
+1 -1
View File
@@ -1590,7 +1590,7 @@ export interface TuiConfig {
}
```
Source: [`packages/ui/tui/src/index.ts:161`](../packages/ui/tui/src/index.ts)
Source: [`packages/ui/tui/src/index.ts:216`](../packages/ui/tui/src/index.ts)
## `@deepseek-ai/dsh-tui-demo`
+23
View File
@@ -1614,6 +1614,29 @@ Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-
Source: [`packages/core/tools/src/index.ts:524`](../../packages/core/tools/src/index.ts)
## `ctx.tui` — `TuiExtensionService` (abstract seam)
Optional terminal-local interaction service provided by one mounted TUI.
The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugins receive only effect-owned overlay sessions.
```ts cordis-catalog
/**
* Queue an interactive overlay owned by the calling plugin fiber.
*
* The TUI displays one overlay at a time in FIFO order. Disposing the caller
* removes a queued overlay or closes an active one before plugin teardown
* settles. This live presentation is neither logged nor replayed.
*
* @param request - component factory, layout constraints, and cancellation.
* @returns the effect-owned overlay session.
* @throws when the TUI has begun shutting down.
*/
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
```
Source: [`packages/ui/tui/src/index.ts:131`](../../packages/ui/tui/src/index.ts)
## `ctx.userInteraction` — `UserInteractionService`
`ctx.userInteraction`: one active UI provider plus an `ask()` surface.
+40 -18
View File
@@ -25,13 +25,11 @@ import { cleanupAcpExampleTest } from './cleanup.ts'
* model nor a sandbox runner is ever exercised.
*
* With-key escalation flow (self-skips without DEEPSEEK_API_KEY or a usable
* platform runner): a scripted ACP client plays the human. The prompt asserts
* a prior denial (the organic denial→marker path lives on the sandbox e2e
* legs and unit tiers), the real model escalates with `sandbox_permissions` +
* `justification`, the bridge prompts THIS client over
* `session/request_permission`, the client answers `allow-once`, and the
* retried write must land ON DISK (world-verified) — under the granted mode,
* a temp-dir session cwd is writable either way.
* platform runner): a scripted ACP client plays the human. The subprocess
* starts read-only, its first real bash write is denied, the model retries with
* `sandbox_permissions` + `justification`, and the bridge prompts THIS client
* over `session/request_permission`. An approved workspace-write retry must
* then land ON DISK (world-verified).
*/
const AGENT: AgentUnderTest = {
@@ -58,14 +56,21 @@ interface Spawned extends LaunchedAcpTestAgent {
permissionRequests: RequestPermissionRequest[]
}
/** Boot the example as an ACP subprocess; the scripted client answers every permission prompt with `answer`. */
function launchExampleAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned {
/** Boot the example with an optional sandbox override; the scripted client answers every permission prompt with `answer`. */
function launchExampleAcpAgent(
cwd: string,
answer: 'allow-once' | 'reject-once',
sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access',
): Spawned {
const permissionRequests: RequestPermissionRequest[] = []
const launched = launchAcpTestAgent({
agent: AGENT,
cwd,
// A dummy key lets the adapter boot keylessly; live tests carry the real key.
env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
env: {
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
DSH_PERMISSION_MODE: sandboxMode,
},
requestPermission(params) {
permissionRequests.push(params)
const option = params.options.find(o => o.optionId === answer)
@@ -78,6 +83,17 @@ function launchExampleAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'
return Object.assign(launched, { permissionRequests })
}
function escalationPrompt(path: string, content: string): string {
return `Create ${path} containing exactly ${JSON.stringify(content)} using bash, not filesystem tools. `
+ 'First try the command without sandbox_permissions. If the sandbox denies it, retry that exact command once '
+ 'with sandbox_permissions set to workspace-write and a one-sentence justification.'
}
function includesReadOnlyDenial(updates: LaunchedAcpTestAgent['updates']): boolean {
return updates.some(update => update.sessionUpdate === 'tool_call_update'
&& JSON.stringify(update.content).includes('[sandbox: file access denied under read-only mode]'))
}
let spawned: Spawned | undefined
let workdir: string | undefined
@@ -137,17 +153,20 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa
describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox composition e2e: the live approval loop', () => {
it('denial → model escalation → editor prompt → allow-once → the retried write lands on disk', async () => {
workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-'))
spawned = launchExampleAcpAgent(workdir, 'allow-once')
const { client, permissionRequests } = spawned
spawned = launchExampleAcpAgent(workdir, 'allow-once', 'read-only')
const { client, permissionRequests, updates } = spawned
await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
const res = await client.prompt({
sessionId,
prompt: [{ type: 'text', text: `The sandbox already denied writing ${workdir}/escalated.txt. Create it now containing exactly "ACP_ESCALATION_OK": `
+ 'one single bash call with sandbox_permissions set to danger-full-access and a one-sentence justification, then stop.' }],
prompt: [{
type: 'text',
text: `${escalationPrompt(join(workdir, 'escalated.txt'), 'ACP_ESCALATION_OK')} Then stop.`,
}],
})
expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
expect(includesReadOnlyDenial(updates)).toBe(true)
// The WORLD: the approved escalated retry landed the write.
const proof = await readFile(join(workdir, 'escalated.txt'), 'utf8')
@@ -166,17 +185,20 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox co
it('a rejected escalation stays denied: no write lands, the turn still ends', async () => {
workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-'))
spawned = launchExampleAcpAgent(workdir, 'reject-once')
const { client, permissionRequests } = spawned
spawned = launchExampleAcpAgent(workdir, 'reject-once', 'read-only')
const { client, permissionRequests, updates } = spawned
await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
const res = await client.prompt({
sessionId,
prompt: [{ type: 'text', text: `The sandbox already denied writing ${workdir}/refused.txt. Create it now containing "NO": `
+ 'one single bash call with sandbox_permissions set to danger-full-access and a one-sentence justification. If that is rejected, stop and say so.' }],
prompt: [{
type: 'text',
text: `${escalationPrompt(join(workdir, 'refused.txt'), 'NO')} If approval is rejected, stop and say so.`,
}],
})
expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
expect(includesReadOnlyDenial(updates)).toBe(true)
// The WORLD: rejected means the file never appeared.
await expect(readFile(join(workdir, 'refused.txt'), 'utf8')).rejects.toThrow()
+4 -4
View File
@@ -9,7 +9,7 @@ Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-<nam
The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) owns the full design; these are the rules you must not violate when writing or reviewing client code:
1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`.
2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking.
2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'conversation.chat.toolview'`).
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + `useSessions`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useStore`, `renderSlot` are the four seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
@@ -20,9 +20,9 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-
The `/client` surface of a UI plugin package is a contract face, not a convenience barrel. Three rules, enforced package-wide (do not restate them as per-file comments):
1. **A UI plugin exports no values beyond what cordis loading needs**`apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType<typeof createXXXStore>`). Types are the extra allowance: contract types (owner shares, injected shapes, view/toolview entry types) export freely. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer.
1. **A UI plugin exports no values beyond what cordis loading needs**`apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType<typeof createXXXStore>`). Types are the extra allowance: contract types (owner shares, injected shapes, composed props aliases) export freely. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer.
2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public surface to make a test compile.
3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot, the view and toolview registries) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself.
3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself.
## ctx discipline (components never see ctx)
@@ -45,7 +45,7 @@ Non-negotiables across the layers:
## Directory regime (plugin packages)
One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through the slot/view/toolview registries in `apply` — never module-level side effects.
One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects.
## Styling
@@ -69,8 +69,9 @@ function buildAlphaLog(): SessionEvent[] {
}
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
// Three view-sample turns (60-62) for the tool-card wire acceptance: one per built-in card
// type. `echo` above stays presenter-less on purpose — it is the no-view fallback sample.
// Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in
// turns 62-63 also exercise their dedicated generic-row icon/title/path summaries. `echo` above
// stays presenter-less as the unknown fallback.
const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
const callId = `fx-call-${turn}`
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
@@ -87,7 +88,8 @@ function buildAlphaLog(): SessionEvent[] {
}
toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
toolTurn(62, 'fx-note', '{"note":"三型卡验收样本"}', '已记录')
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
return events as unknown as SessionEvent[]
}
@@ -112,8 +114,10 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
card: 'diff', title: `Write ${str(args.path)}`,
diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
}
case 'fx-note':
return { card: 'generic', title: '记录笔记', kind: 'edit', rawInput: args }
case 'edit':
return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args }
case 'write':
return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args }
default:
return undefined // echo et al: the documented no-view fallback path
}
+1 -1
View File
@@ -13,6 +13,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
- **Scope teardown is watch-approximated** — the most recently resolved binding stands in for "who is watching"; a removed-while-watched session's scope survives until the watch moves away, not until true observer count reaches zero.
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`cell()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land.
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).
- **`SessionSummary.title` is a display projection** — the wire summary carries no title yet; the cwd basename stands in, then the raw id.
+1 -1
View File
@@ -54,7 +54,7 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
*/
export type ClientContext = Context
/** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
/**
@@ -122,6 +122,7 @@ export interface RunningToolCall {
callView: ToolCallView | null
}
/** In-progress assistant output (chunk accumulator product). */
export interface PartialAssistant {
turn: number
@@ -5,13 +5,14 @@
* slot-parity design), session scope tree (mintScope pattern: no-op plugin
* Fiber + ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
*
* Scope lifecycle is watch-driven: a scope is minted lazily on first
* resolution; a session leaving the list tears its scope down only when
* nobody is watching it. "Watched" is approximated as the most recently
* resolved binding id — SessionProvider re-resolves on every selection
* change (keyed remount), so a switch away always re-evaluates the deferred
* teardown; a host-side death without list removal keeps the scope (frozen
* read-only view).
* Scope lifecycle is stage-driven: a scope is minted lazily on first
* resolution (pure — resolution has no side effects and is render-safe);
* the event window and deferred teardown key off the STAGED session, which
* follows `list.current` exactly. Staging is the open signal: the window
* opens ⟺ the session is on stage (today the stage is `current`; the staged
* state can widen to a multi-pane list later). A session leaving the list
* tears its scope down immediately unless it is the staged one, whose scope
* survives frozen (read-only view) until the stage moves on.
*/
import type { Context, Fiber } from 'cordis'
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
@@ -97,9 +98,14 @@ export class SessionsService {
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
private readonly scopes = new Map<SessionId, ScopeRecord>()
/** Most recently resolved binding id — the watch approximation for deferred teardown. */
/**
* The staged session id — follows `list.current` exactly, holding its last
* defined value across masked gaps (a transiently absent selection blanks
* `current` without moving the stage, so reconnect re-pulls and removals
* keep the staged scope's frozen view alive until the stage moves on).
*/
private watched: SessionId | undefined
/** Removed-while-watched sessions whose teardown waits for the watch to move away. */
/** Removed-while-staged sessions whose teardown waits for the stage to move away. */
private readonly deferredRemovals = new Set<SessionId>()
/**
@@ -115,6 +121,13 @@ export class SessionsService {
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
this.manager.subscribe(() => { this.projectList() })
// Stage follower: every current write (open() and projection alike)
// re-evaluates staging, so startup restore (persisted selection validated
// by the projection) and reconnect resurfacing open their window with no
// dedicated code path. Safe to run synchronously inside the store notify:
// the follower writes no list state — session.open()'s synchronous prefix
// touches only session-side state and its own microtask-batched notifier.
this.list.subscribe(() => { this.followCurrent() })
rootCtx.reflect.provide('sessions', this, undefined)
}
@@ -152,35 +165,50 @@ export class SessionsService {
}
/**
* Resolve the stable session binding (SessionProvider's resolveBinding feed).
* Resolve the stable session binding (scope-addressed assembly feed). Pure
* resolution — no staging, no window side effects.
* @param id - session id.
* @returns binding, or undefined for a session neither listed nor already scoped.
*/
binding(id: SessionId): SessionBinding | undefined {
const record = this.resolve(id)
if (record === undefined) return undefined
if (this.watched !== id) {
this.watched = id
this.sweepDeferred()
}
return record.binding
return this.resolve(id)?.binding
}
/**
* Resolve the render-layer session cell (SessionProvider's feed through
* the renderer host; ctx never enters the render layer). Marks the session
* watched, same as {@link SessionsService.binding}.
* the renderer host; ctx never enters the render layer). Pure resolution
* render-safe: SessionProvider calls this during render, so no staging, no
* window side effects (StrictMode double-invokes and concurrent discarded
* passes must stay free).
* @param id - session id.
* @returns cell, or undefined for a session neither listed nor already scoped.
*/
cell(id: string): SessionCell | undefined {
const record = this.resolve(id as SessionId)
if (record === undefined) return undefined
if (this.watched !== id) {
this.watched = id as SessionId
this.sweepDeferred()
return this.resolve(id as SessionId)?.cell
}
/**
* Move the stage to the list's current session: sweep teardowns deferred
* behind the previous occupant and pull the new occupant's history window.
* Staging IS the open signal — the window opens ⟺ the session is on stage
* — and open() is idempotent (an in-flight or completed open no-ops; a
* failed one retries the next time current is touched).
*/
private followCurrent(): void {
const current = this.list.getSnapshot().current
// A masked gap (current blanked while the selection's session is
// transiently absent) holds the stage: tearing down on the gap would
// destroy exactly the frozen scope the mask exists to preserve.
if (current === undefined || current === this.watched) return
this.watched = current
this.sweepDeferred()
const record = this.resolve(current)
/* v8 ignore next 3 -- defensive: current is always a listed id (open()
* validates and the projection masks absent selections), so resolve
* cannot miss; kept so a future current writer cannot crash the notify. */
if (record !== undefined) {
void record.binding.session.open()
}
return record.cell
}
/**
@@ -246,7 +274,7 @@ export class SessionsService {
this.pruneScopes(byId)
}
/** Tear down scopes for removed sessions nobody watches; the watched one defers until the watch moves. */
/** Tear down scopes for removed sessions off stage; the staged one defers until the stage moves. */
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
for (const [id, record] of this.scopes) {
if (byId[id] !== undefined) continue
@@ -268,11 +296,11 @@ export class SessionsService {
this.rootCtx.get('slots')?.pruneStoreScope(id)
}
/** Run deferred teardowns whose session is no longer watched (called when the watch moves). */
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
private sweepDeferred(): void {
for (const id of [...this.deferredRemovals]) {
/* v8 ignore next -- defensive: only the watched id ever defers, and every
* watch move sweeps first, so the set cannot contain the id the watch just
/* v8 ignore next -- defensive: only the staged id ever defers, and every
* stage move sweeps first, so the set cannot contain the id the stage just
* moved to; kept as a guard against future extra sweep call sites. */
if (id === this.watched) continue
// Still absent from the list? (A re-added id cancels the deferred teardown.)
+2 -1
View File
@@ -54,7 +54,6 @@ export class FakeApiClient implements IApiClient {
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onRespond: (message: ClientResponse) => Promise<RpcReceipt> = () => Promise.resolve({ accepted: true })
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
@@ -94,6 +93,8 @@ export class FakeApiClient implements IApiClient {
host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.hostConns, signal, onOpen),
}
onRespond: (message: ClientResponse) => Promise<RpcReceipt> = () => Promise.resolve({ accepted: true })
respond(message: ClientResponse): Promise<RpcReceipt> {
return this.record('respond', message, this.onRespond(message))
}
@@ -277,7 +277,7 @@ describe('pending interactions', () => {
const wait = session.getSnapshot().pending[0]!
session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq1' as never, outcome: 'answered' })
expect(session.getSnapshot().pending).toEqual([])
expect(() => wait.respond({ ok: false, error: { code: 'cancelled', message: 'x', details: {} } }))
expect(() => wait.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } }))
.toThrow('already settled')
expect(api.callsOf('respond')).toEqual([])
})
@@ -610,7 +610,7 @@ describe('resync', () => {
expect(after).not.toBe(before)
expect(after.key).toBe(before.key)
// Superseded ≠ settled: an in-flight respond on the stale reference still reaches the host.
await before.respond({ ok: false, error: { code: 'cancelled', message: 'x', details: {} } })
await before.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
expect(api.callsOf('respond')).toMatchObject([{ rpcId: 'rq-replay' }])
})
@@ -2,8 +2,9 @@
* SessionsService: list store projection (manager → {ids, byId, current}
* with derived titles), the migrated current-selection account (open
* validation, persisted mask semantics, cell resolution), scope-tree
* lifecycle (lazy mint / frozen survival / removed teardown with watch
* deferral), binding identity, ancestry walk, create.
* lifecycle (lazy mint / frozen survival / removed teardown with staged
* deferral — the stage follows list.current), binding identity, ancestry
* walk, create.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -76,21 +77,21 @@ describe('scope tree', () => {
expect(binding?.ctx).toBe(scoped)
})
it('tears down an unwatched removed session but defers the watched one until the watch moves', async () => {
it('tears down an off-stage removed session but defers the staged one until the stage moves', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
const ctx1 = b.svc.scope(sid('s1'))
b.svc.binding(sid('s1')) // s1 is watched
b.svc.scope(sid('s2')) // s2 scoped but not watched
b.svc.open(sid('s1')) // s1 staged (current)
b.svc.scope(sid('s2')) // s2 scoped but off stage
await feedList(b, [{ id: 's1' }]) // s2 removed, unwatched: torn down
await feedList(b, [{ id: 's1' }]) // s2 removed, off stage: torn down
expect(b.svc.scope(sid('s2'))).toBeUndefined()
await feedList(b, []) // s1 removed while watched: deferred, scope survives
await feedList(b, []) // s1 removed while staged (current masks): deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBe(ctx1)
await feedList(b, [{ id: 's3' }])
b.svc.binding(sid('s3')) // watch moves: deferred teardown sweeps s1
b.svc.open(sid('s3')) // stage moves: deferred teardown sweeps s1
expect(b.svc.scope(sid('s1'))).toBeUndefined()
})
@@ -106,10 +107,10 @@ describe('scope tree', () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const scoped = b.svc.scope(sid('s1'))
b.svc.binding(sid('s1'))
await feedList(b, []) // removed while watched → deferred
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears
b.svc.binding(sid('s2')) // watch moves; sweep must NOT tear down the re-listed s1
b.svc.open(sid('s1'))
await feedList(b, []) // removed while staged → deferred
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears (current resurfaces, stage unchanged)
b.svc.open(sid('s2')) // stage moves; sweep must NOT tear down the re-listed s1
expect(b.svc.scope(sid('s1'))).toBe(scoped)
})
})
@@ -168,15 +169,52 @@ describe('cell (render-layer session kit)', () => {
expect(b.svc.cell('ghost')).toBeUndefined()
})
it('moves the watch like binding(): switching cells sweeps a deferred removal', async () => {
it('cell()/binding() are pure resolution: no staging, no deferred sweep', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.cell('s1') // watched
await feedList(b, []) // removed while watched → deferred, scope survives
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.open(sid('s1')) // staged
b.svc.cell('s2') // resolution only — must NOT move the stage
b.svc.binding(sid('s2'))
await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBeDefined()
await feedList(b, [{ id: 's2' }])
b.svc.cell('s2') // watch moves → sweep tears s1 down
expect(b.svc.scope(sid('s1'))).toBeUndefined()
})
it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
// Resolution is addressing, not staging: no window pull.
b.svc.scope(sid('s1'))
b.svc.cell('s1')
b.svc.binding(sid('s1'))
expect(historyCalls()).toHaveLength(0)
b.svc.open(sid('s1'))
expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
// Same current again: no second pull.
b.svc.open(sid('s1'))
expect(historyCalls()).toHaveLength(1)
// Stage moves: the new occupant opens.
b.svc.open(sid('s2'))
expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1', 's2'])
})
it('startup restore: a persisted selection validated by the first projection opens its window unprompted', async () => {
const storage = new Map<string, string>([
['dsh.sessions.current', JSON.stringify({ sessionId: 's1' })],
])
vi.stubGlobal('localStorage', {
getItem: (k: string) => storage.get(k) ?? null,
setItem: (k: string, v: string) => { storage.set(k, v) },
})
try {
const b = bench()
expect(b.api.calls.filter(c => c.method === 'session.history')).toHaveLength(0)
await feedList(b, [{ id: 's1' }]) // projection validates the persisted id → current lands → stage follows
const historyCalls = b.api.calls.filter(c => c.method === 'session.history')
expect(historyCalls.map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
} finally {
vi.unstubAllGlobals()
}
})
})
@@ -187,12 +225,13 @@ describe('slot-store scope prune hook', () => {
b.ctx.reflect.provide('slots', { pruneStoreScope })
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.scope(sid('s1'))
b.svc.binding(sid('s2')) // s2 watched
await feedList(b, []) // s1 unwatched → immediate drop; s2 watched → deferred
b.svc.scope(sid('s2'))
b.svc.open(sid('s2')) // s2 staged
await feedList(b, []) // s1 off stage → immediate drop; s2 staged → deferred
expect(pruneStoreScope).toHaveBeenCalledWith('s1')
expect(pruneStoreScope).not.toHaveBeenCalledWith('s2')
await feedList(b, [{ id: 's3' }])
b.svc.binding(sid('s3')) // watch moves → deferred sweep drops s2
b.svc.open(sid('s3')) // stage moves → deferred sweep drops s2
expect(pruneStoreScope).toHaveBeenCalledWith('s2')
})
@@ -242,44 +281,46 @@ describe('coverage tails (branch duals)', () => {
expect(byId[sid('empty-cwd')]?.title).toBe('empty-cwd')
})
it('binding for an unknown session returns undefined without moving the watch', async () => {
it('binding for an unknown session returns undefined and leaves the staged scope intact', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.binding(sid('s1'))
b.svc.open(sid('s1'))
expect(b.svc.binding(sid('ghost'))).toBeUndefined()
// Watch unchanged: removing s1 defers (still watched), proving the ghost lookup did not steal the watch.
// Stage unchanged: removing s1 defers (still staged), proving the ghost lookup touched nothing.
await feedList(b, [])
expect(b.svc.scope(sid('s1'))).toBeDefined()
})
it('sweep skips the id that is itself still watched and tolerates a scope record already gone', async () => {
it('a masked current gap holds the stage (no teardown, no re-open) until the stage moves', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.binding(sid('s1'))
await feedList(b, []) // deferred removal of the watched id
// Re-resolving the SAME watched id: sweep runs but must skip it (watched-continue branch).
expect(b.svc.binding(sid('s1'))).toBeDefined()
b.svc.open(sid('s1'))
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
expect(historyCalls()).toHaveLength(1)
await feedList(b, []) // removed while staged: current masks to undefined, stage holds → deferred
expect(b.svc.scope(sid('s1'))).toBeDefined()
// Resurfacing re-projects current = s1: same stage occupant, no second pull.
await feedList(b, [{ id: 's1' }])
expect(historyCalls()).toHaveLength(1)
expect(b.svc.list.getSnapshot().current).toBe('s1')
})
it('sweep hits both deferral edges: watched-id skip and an already-vacated scope record', async () => {
it('sweep hits both deferral edges: staged-id skip and an already-vacated scope record', async () => {
const b = bench()
await feedList(b, [{ id: 'a' }, { id: 'b' }])
b.svc.binding(sid('a'))
b.svc.binding(sid('b')) // watch: b; both scoped
await feedList(b, []) // a removed unwatched → torn immediately; b removed watched → deferred
// Move the watch to a THIRD id while b stays deferred: sweep now walks a
// set containing b (torn) — and the watched-continue branch fires when the
// deferral set still holds the current watch target.
b.svc.scope(sid('a'))
b.svc.open(sid('b')) // stage: b; both scoped
await feedList(b, []) // a removed off stage → torn immediately; b removed staged → deferred
// Move the stage to a THIRD id while b stays deferred: sweep walks a set
// containing b (torn).
await feedList(b, [{ id: 'c' }])
b.svc.binding(sid('c'))
b.svc.open(sid('c'))
expect(b.svc.scope(sid('b'))).toBeUndefined()
// Deferral for an id whose record was never minted: force-add via removed
// list state (scope teardown raced) — sweep must tolerate the missing record.
await feedList(b, [])
b.svc.binding(sid('c')) // c now watched+removed → deferred
// Deferral for an id whose record was never minted: force the deferral
// via removed list state — sweep must tolerate the missing record.
await feedList(b, []) // c removed while staged → deferred (scope exists)
await feedList(b, [{ id: 'd' }])
b.svc.binding(sid('d')) // sweep tears c
b.svc.open(sid('d')) // sweep tears c
expect(b.svc.scope(sid('c'))).toBeUndefined()
})
+10 -5
View File
@@ -1,10 +1,16 @@
# @deepseek-ai/dsh-client-ui-conversation
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. The keyed `conversation.composer` child slot (declared by the conversation registration) lets pending interaction features replace InputBar without moving interaction state into the skeleton. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to both the conversation and details registrations, so the two session slots share one instance per session (selection written by conversation, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, view registry read face, startSession chain).
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction.
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, tab read face, details/paging callbacks, startSession chain).
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
## Model Experience
@@ -20,5 +26,4 @@ None; this package neither assembles nor sends a provider request.
- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **Approval cards are display-only placeholders** — question requests use the composer slot, while Web approval answering remains deferred.
- **Module-level toolview caches are single-bundle state** — the inject cache and registry maps must reach cross-bundle consumers through the package export surface and loader module table, never by a second bundle copy.
- **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project.
@@ -34,7 +34,6 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-i18n": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
@@ -1,39 +1,32 @@
/**
* Client plugin body: provide the conversation service and toolview registry,
* register the conversation/details slot occupants and the no-session empty
* state, and mount the chat view with its samples. Assembly only — components
* receive everything through props: the framework standard kit and store
* faces arrive automatically from the declarations below; the inject
* factories contribute the plain-data-and-callbacks business face (design §5).
* Client plugin body: register the conversation/details slot occupants and
* the no-session empty state, contribute the chat entry into the
* 'conversation.view' ring that the conversation registration declares, then
* mount the conversation service (class plugin) and the bash toolview sample.
* Assembly only — components receive everything through props: the framework
* standard kit and store faces arrive automatically from the declarations
* below; the inject factories contribute the plain-data-and-callbacks
* business face (design §5). Tool rows are ordinary keyed-slot registrations
* into 'conversation.chat.toolview' — no dedicated registry exists.
*/
import type { Context } from 'cordis'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId, SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
import type { SelectionTarget } from './contract/views.ts'
import type { ConversationInjected, DetailsInjected, EmptyStateInjected } from './contract/slots.ts'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ViewTab } from './contract/views.ts'
import type {
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
} from './contract/slots.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import { ToolViewRegistry } from './toolviews/registry.ts'
import { childSessionScope, registerChat } from './chat/register.ts'
import { registerBashSamples } from './toolviews/bash-sample.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { EmptyState } from './skeleton/EmptyState.tsx'
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
export const inject = ['slots', 'layout', 'sessions', 'i18n']
/** Resolve a service via ctx.get, failing loud. Property access is reserved
* for contexts whose fiber declares the inject (scope fibers do not). */
// T is the caller-named cast target; inlining `as T` per call site would scatter the budgeted cast.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
function need<T>(ctx: Context, name: string): T {
const value = ctx.get(name) as T | undefined
if (value === undefined) throw new Error(`ui-conversation: ${name} service unavailable`)
return value
}
export const inject = ['slots', 'layout', 'sessions']
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
@@ -49,54 +42,51 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
const sessions = need<SessionsService>(ctx, 'sessions')
const layout = need<LayoutService>(ctx, 'layout')
const i18n = need<I18nService>(ctx, 'i18n')
const slots = need<SlotsService>(ctx, 'slots')
const conversation = new ConversationService(ctx)
const toolviews = new ToolViewRegistry()
ctx.provide('toolviews', toolviews)
const t = i18n.bind('conversation')
// Chat view + StatsLine footer; bash samples assembled here (apply is the
// only cross-domain point — chat consumes the resolver face, samples come
// from the toolviews domain). registerView inside registerChat is already
// effect-scoped; the raw sample registrations need the effect wrapper to
// ride the fiber cascade.
ctx.effect(
() => registerChat({ conversation, toolviews, t }),
'ui-conversation: chat view')
ctx.effect(
() => registerBashSamples(toolviews, childSessionScope(sessions.list)),
'ui-conversation: bash toolview samples')
const sessions = ctx.sessions
const layout = ctx.layout
const slots = ctx.slots
// Shared store handle, constructed here so its identity lives and dies with
// this fiber (a module-level handle would be a de-facto singleton). Both
// session-slot registrations declare it; same scope key = same instance, so
// conversation writes and details reads meet in one store.
const chat = createChatStore()
// this fiber (a module-level handle would be a de-facto singleton). The
// conversation, chat-view, and details registrations all declare it; same
// scope key = same instance, so chat-view selection writes and details
// reads meet in one store.
const chatStore = createChatStore()
// Tab projection over the view ring's ledger (list entries carry id/order/
// label as registration options; the ledger keeps them order-sorted).
const viewTabs = (): ViewTab[] => {
const tabs: ViewTab[] = []
for (const entry of slots.entries('conversation.view')) {
/* v8 ignore next -- unreachable: list registration validates id at load. */
if (entry.options.id === undefined) continue
tabs.push({ id: entry.options.id, label: entry.options.label ?? entry.options.id })
}
return tabs
}
// Conversation occupant. Declaring the view ring here is claiming it:
// ConversationRoot is the only component authorized to render the ring.
slots.register({
name: 'conversation',
// Declaring the chain composer slot here both creates it and authorizes
// ConversationRoot (the takeover dispatch site) to render it; takeover
// plugins (ui-question) register selector-routed composer replacements.
// The composer chain rides the same declaration table: takeover plugins
// register selector-routed replacements of the InputBar.
children: {
'conversation.view': { kind: 'list', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
},
store: chat,
inject: (sessionId: SessionId, actions: BoundActions<typeof chat>): ConversationInjected => {
const session = sessions.manager.get(sessionId)
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ConversationInjected => {
// History pull is NOT triggered here: the runtime sessions service opens
// the event window when the watch lands on the session (cell/binding
// resolution) — an inject factory assembles callbacks, it has no side
// effect on session state.
const scoped = scopedConversation(sessions, sessionId)
// Watch-driven history pull: assembling the surface IS the watch signal
// (once per entry x session; open() is idempotent and self-recovers).
void session.open()
return {
views: {
list: () => conversation.views(),
subscribe: fn => conversation.subscribeViews(fn),
version: () => conversation.viewsVersion(),
list: viewTabs,
subscribe: fn => slots.subscribe('conversation.view', fn),
version: () => slots.getVersion('conversation.view'),
},
send: (text, mode) => {
const trimmed = text.trim()
@@ -113,19 +103,46 @@ export function apply(ctx: Context): void {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
})
},
openDetails: (target: SelectionTarget) => {
actions.select(target)
layout.openDetails()
},
loadOlder: () => { void session.loadOlder() },
open: (target: SessionId) => { sessions.open(target) },
}
},
}, ConversationRoot)
// The chat view: first entry of the ring this package just declared.
// Declaring the keyed toolview hole here is claiming it: ChatView is the
// only component authorized to render per-tool rows. Shares the chat
// store, so its selection writes land in the same per-session instance the
// details panel reads.
slots.register({
name: 'conversation.view',
id: 'chat',
order: 0,
label: 'Chat',
children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } },
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => ({
openDetails: (target) => {
actions.select(target)
layout.openDetails()
},
loadOlder: () => { void sessions.manager.get(sessionId).loadOlder() },
}),
}, ChatView)
// Class-plugin mount (packages/AGENTS.md service form): the service
// registers itself as `conversation` and lives on its own child fiber.
// Mounted AFTER the chat entry register above — construction guarantee for
// toolview registrants using `inject: ['conversation']` as their load-order
// seam: the service being present implies the chat entry (and with it the
// 'conversation.chat.toolview' declaration) is on the ledger.
ctx.plugin(ConversationService)
// The bash sample rides that exact seam, in third-party posture.
ctx.plugin(bashToolviewSample)
slots.register({
name: 'details',
store: chat,
store: chatStore,
inject: (): DetailsInjected => ({
closeDetails: () => { layout.closeDetails() },
}),
@@ -134,7 +151,15 @@ export function apply(ctx: Context): void {
slots.register({
name: 'conversation.empty',
inject: (): EmptyStateInjected => ({
startSession: opts => conversation.startSession(opts),
// ctx.get, not ctx.conversation: the service mounts on this plugin's
// own child fiber, so it is not in the inject topology the property
// proxy enforces; get reads the global store and stays loud on a torn
// boot through the optional-chain throw below.
startSession: (opts) => {
const conversation = ctx.get('conversation')
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
return conversation.startSession(opts)
},
}),
}, EmptyState)
}
@@ -1,8 +1,9 @@
// AssistantMarkdown: renders assistant blocks in order — markdown text body,
// reasoning as the figma Think summary row (expand = indented gray text),
// other-block JSON fallback. Tool-call heads are NOT rendered here: the chat
// view groups them into tool rows via the toolview outlet (figma step-summary
// flow). Shared by finalized nodes and the streaming partial (pulse marker).
// view groups them into tool rows through its keyed toolview slot (figma
// step-summary flow). Shared by finalized nodes and the streaming partial
// (pulse marker).
import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -32,6 +33,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
summary={firstLine(text)}
body={text}
state={running ? 'running' : 'ok'}
expandOnRowClick
/>
)
}
@@ -1,53 +1,55 @@
// ChatView: the default conversation view — message flow with user bubbles,
// assistant narration, tool summary rows grouped into step runs, pending
// cards, paging and bottom-follow. Created via factory so plugin deps
// (toolviews registry, i18n) arrive by closure, never by import.
// cards, paging, bottom-follow, and the session stats line under the flow
// (chrome dissolved into the view: the footer is part of what a chat view
// IS, not registration metadata). Pure component registered directly; its
// registration declares the keyed 'conversation.chat.toolview' hole, so tool
// rows render through the props renderSlot share (entryKey = tool name,
// GenericToolCard as the render-site fallback).
//
// Render economics (architecture RFC performance model): the list parent
// subscribes to snapshot segments that do NOT change per streaming chunk
// (nodes/runningCalls/pending keep their references across chunk batches), so
// during a token storm only StreamingTail re-renders; history rows hold via
// memo on cache-stable node slices. Selection changes re-render the parent
// map but only rows whose own selected bit flipped.
// map but only rows whose own selected bit flipped. renderSlot is
// entry-identity-stable (framework binding cache), so passing it through
// memoized rows never churns them.
import {
memo, useLayoutEffect, useMemo, useRef, useState, type FC, type ReactNode,
memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
} from 'react'
import type {
ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode,
ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ConvViewProps, SelectionTarget, Translate } from '../contract/views.ts'
import type { ToolViewProps } from '../contract/toolview.ts'
import type { ToolViewResolver } from '../contract/toolview.ts'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { SelectionTarget } from '../contract/views.ts'
import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
import { MessageItem } from './MessageItem.tsx'
import { PendingCard } from './PendingCard.tsx'
import { ToolViewOutlet } from './ToolViewOutlet.tsx'
import { StatsLine } from './StatsLine.tsx'
import css from './ChatView.module.css'
/** Plugin-supplied closure deps (assembled in registerChat, apply world). */
export interface ChatViewDeps {
toolviews: ToolViewResolver
t: Translate
}
const FOLLOW_THRESHOLD = 24
type OpenDetails = (target: SelectionTarget) => void
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
type RenderToolRow = ChatViewSlotProps['renderSlot']
/** ui-slots' UseSession is deliberately wide (dependency direction); the
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
/** One tool call row (result or running): builds the bound ToolViewProps. */
const CallRow = memo(function CallRow({ registry, sessionId, useSession, t, callId, toolName, block, seq, onOpenDetails, selected }: {
registry: ToolViewResolver
sessionId: SessionId
useSession: ConvViewProps['useSession']
t: Translate
/** One tool call row (result or running): dispatches through the keyed
* toolview slot with the owner payload; unregistered tools fall back to
* GenericToolCard at this render site. */
const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected }: {
renderSlot: RenderToolRow
callId: string
toolName: string
block: ToolResultNode | RunningToolCall
@@ -56,24 +58,23 @@ const CallRow = memo(function CallRow({ registry, sessionId, useSession, t, call
onOpenDetails: OpenDetails
selected: boolean
}) {
const viewProps = useMemo<ToolViewProps>(() => ({
callId, toolName, block, useSession,
actions: { openDetails: () => onOpenDetails({ turnSeq: seq, callId, toolName }) },
t,
}), [callId, toolName, block, useSession, seq, onOpenDetails, t])
const owner = useMemo(() => ({
callId, toolName, block,
openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) },
}), [callId, toolName, block, seq, onOpenDetails])
return (
<div className={css.callRow} data-selected={selected || undefined}>
<ToolViewOutlet registry={registry} sessionId={sessionId} toolName={toolName} viewProps={viewProps} />
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard {...owner} />,
})}
</div>
)
})
/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */
const ToolGroup = memo(function ToolGroup({ registry, sessionId, useSession, t, results, onOpenDetails, selectedCallId }: {
registry: ToolViewResolver
sessionId: SessionId
useSession: ConvViewProps['useSession']
t: Translate
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId }: {
renderSlot: RenderToolRow
results: readonly ToolResultNode[]
onOpenDetails: OpenDetails
/** Only set when the selected call lives in THIS group (memo economy). */
@@ -84,10 +85,7 @@ const ToolGroup = memo(function ToolGroup({ registry, sessionId, useSession, t,
{results.map((node) => (
<CallRow
key={node.callId}
registry={registry}
sessionId={sessionId}
useSession={useSession}
t={t}
renderSlot={renderSlot}
callId={node.callId}
toolName={node.call?.name ?? ''}
block={node}
@@ -114,182 +112,166 @@ function StreamingTail({ useSession, onGrow }: {
return <AssistantMarkdown blocks={partial.blocks} streaming />
}
/**
* Build the chat view component over plugin deps.
* @param deps - toolview registry and bound translator.
* @returns the ConvViewProps component registered as the chat view.
*/
export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
const { toolviews, t } = deps
/** The chat view slot entry: pure component over the composed props (tool rows render through the declared keyed hole's renderSlot share). */
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
const nodes = useSession((s) => s.nodes)
const runningCalls = useSession((s) => s.runningCalls)
const pending = useSession((s) => s.pending)
const openState = useSession((s) => s.openState)
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}${s.openError.code}`)
const hasMore = useSession((s) => s.hasMore)
const loadingOlder = useSession((s) => s.loadingOlder)
const selectedCallId = useStore((s) => s.selection?.callId)
return function ChatView({ sessionId, useSession: useSessionWide, useStore, actions }: ConvViewProps) {
const useSession = useSessionWide as UseConversation
const nodes = useSession((s) => s.nodes)
const runningCalls = useSession((s) => s.runningCalls)
const pending = useSession((s) => s.pending)
const openState = useSession((s) => s.openState)
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}${s.openError.code}`)
const hasMore = useSession((s) => s.hasMore)
const loadingOlder = useSession((s) => s.loadingOlder)
const selectedCallId = useStore((s) => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
const listRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true)
const [atBottom, setAtBottom] = useState(true)
/** Paging anchor: height/position at click, compensated after the prepend lands. */
const anchorRef = useRef<{ h: number; t: number } | null>(null)
const firstSeqRef = useRef<number | null>(null)
const openedRef = useRef(false)
const lastKeyRef = useRef<string | null>(null)
const listRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true)
const [atBottom, setAtBottom] = useState(true)
/** Paging anchor: height/position at click, compensated after the prepend lands. */
const anchorRef = useRef<{ h: number; t: number } | null>(null)
const firstSeqRef = useRef<number | null>(null)
const openedRef = useRef(false)
const lastKeyRef = useRef<string | null>(null)
const firstSeq = nodes[0]?.seq ?? null
const lastItem = items[items.length - 1]
const firstSeq = nodes[0]?.seq ?? null
const lastItem = items[items.length - 1]
const toBottom = (el: HTMLDivElement): void => {
el.scrollTop = el.scrollHeight
atBottomRef.current = true
setAtBottom(true)
}
useLayoutEffect(() => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
if (el === null) return
// Open completed: jump to the bottom once.
if (openState === 'open' && !openedRef.current) {
openedRef.current = true
toBottom(el)
firstSeqRef.current = firstSeq
lastKeyRef.current = lastItem?.key ?? null
return
}
// Prepend (head seq decreased): compensate by the height delta.
if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) {
el.scrollTop = anchorRef.current.t + (el.scrollHeight - anchorRef.current.h)
anchorRef.current = null
firstSeqRef.current = firstSeq
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
lastKeyRef.current = lastItem?.key ?? null
return
}
firstSeqRef.current = firstSeq
// Own words must be visible: a new trailing user node force-scrolls
// (send lives in the composer, so arrival is detected here, not armed there).
const lastKey = lastItem?.key ?? null
const appendedUser = lastKey !== lastKeyRef.current
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
lastKeyRef.current = lastKey
if (appendedUser || atBottomRef.current) toBottom(el)
})
const onScroll = (): void => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the handler only fires on the mounted element. */
if (el === null) return
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
}
// Follow streaming growth the parent never re-renders for (stable ref).
// The ref starts null and is assigned every render, so the placeholder
// initializer a function initial value would need never exists.
const followRef = useRef<(() => void) | null>(null)
followRef.current = () => {
const el = listRef.current
if (el !== null && atBottomRef.current) el.scrollTop = el.scrollHeight
}
const onGrow = useRef(() => followRef.current?.()).current
const loadOlder = (): void => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
if (el !== null) anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
actions.loadOlder()
}
const renderItem = (item: ChatFlowItem): ReactNode => {
if (item.kind === 'tool-group') {
const inGroup = selectedCallId !== undefined
&& item.results.some((r) => r.callId === selectedCallId)
return (
<ToolGroup
key={item.key}
registry={toolviews}
sessionId={sessionId}
useSession={useSession}
t={t}
results={item.results}
onOpenDetails={actions.openDetails}
selectedCallId={inGroup ? selectedCallId : undefined}
/>
)
}
const node: ConversationNode = item.node
if (node.kind === 'assistant') {
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return <MessageItem key={item.key} node={node} />
}
return (
<div className={css.root}>
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
<div className={css.column}>
{openState === 'loading' && <div className={css.hint}></div>}
{openState === 'error' && <div className={css.openError}>{openErrorMessage}</div>}
{hasMore && (
<div className={css.older}>
<button type="button" disabled={loadingOlder} onClick={loadOlder}>
{loadingOlder ? '加载中…' : '加载更早'}
</button>
</div>
)}
{items.map(renderItem)}
<StreamingTail useSession={useSession} onGrow={onGrow} />
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map((call) => (
<CallRow
key={call.callId}
registry={toolviews}
sessionId={sessionId}
useSession={useSession}
t={t}
callId={call.callId}
toolName={call.name}
block={call}
seq={call.turn}
onOpenDetails={actions.openDetails}
selected={call.callId === selectedCallId}
/>
))}
</div>
)}
{pending.map((item) => item.kind === 'approval'
? <PendingCard key={item.key} item={item} />
: null)}
</div>
</div>
{!atBottom && (
<button
type="button"
className={css.toBottom}
aria-label="回到底部"
onClick={() => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
if (el !== null) toBottom(el)
}}
>
<IconChevronDownOutline14 />
</button>
)}
</div>
)
const toBottom = (el: HTMLDivElement): void => {
el.scrollTop = el.scrollHeight
atBottomRef.current = true
setAtBottom(true)
}
useLayoutEffect(() => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
if (el === null) return
// Open completed: jump to the bottom once.
if (openState === 'open' && !openedRef.current) {
openedRef.current = true
toBottom(el)
firstSeqRef.current = firstSeq
lastKeyRef.current = lastItem?.key ?? null
return
}
// Prepend (head seq decreased): compensate by the height delta.
if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) {
el.scrollTop = anchorRef.current.t + (el.scrollHeight - anchorRef.current.h)
anchorRef.current = null
firstSeqRef.current = firstSeq
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
lastKeyRef.current = lastItem?.key ?? null
return
}
firstSeqRef.current = firstSeq
// Own words must be visible: a new trailing user node force-scrolls
// (send lives in the composer, so arrival is detected here, not armed there).
const lastKey = lastItem?.key ?? null
const appendedUser = lastKey !== lastKeyRef.current
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
lastKeyRef.current = lastKey
if (appendedUser || atBottomRef.current) toBottom(el)
})
const onScroll = (): void => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the handler only fires on the mounted element. */
if (el === null) return
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
}
// Follow streaming growth the parent never re-renders for (stable ref).
// The ref starts null and is assigned every render, so the placeholder
// initializer a function initial value would need never exists.
const followRef = useRef<(() => void) | null>(null)
followRef.current = () => {
const el = listRef.current
if (el !== null && atBottomRef.current) el.scrollTop = el.scrollHeight
}
const onGrow = useRef(() => followRef.current?.()).current
const loadOlderAnchored = (): void => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
if (el !== null) anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
loadOlder()
}
const renderItem = (item: ChatFlowItem): ReactNode => {
if (item.kind === 'tool-group') {
const inGroup = selectedCallId !== undefined
&& item.results.some((r) => r.callId === selectedCallId)
return (
<ToolGroup
key={item.key}
renderSlot={renderSlot}
results={item.results}
onOpenDetails={openDetails}
selectedCallId={inGroup ? selectedCallId : undefined}
/>
)
}
const node: ConversationNode = item.node
if (node.kind === 'assistant') {
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return <MessageItem key={item.key} node={node} />
}
return (
<div className={css.root}>
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
<div className={css.column}>
{openState === 'loading' && <div className={css.hint}></div>}
{openState === 'error' && <div className={css.openError}>{openErrorMessage}</div>}
{hasMore && (
<div className={css.older}>
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
{loadingOlder ? '加载中…' : '加载更早'}
</button>
</div>
)}
{items.map(renderItem)}
<StreamingTail useSession={useSession} onGrow={onGrow} />
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map((call) => (
<CallRow
key={call.callId}
renderSlot={renderSlot}
callId={call.callId}
toolName={call.name}
block={call}
seq={call.turn}
onOpenDetails={openDetails}
selected={call.callId === selectedCallId}
/>
))}
</div>
)}
{pending.map((item) => <PendingCard key={item.key} item={item} />)}
</div>
</div>
<StatsLine useSession={useSession} />
{!atBottom && (
<button
type="button"
className={css.toBottom}
aria-label="回到底部"
onClick={() => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
if (el !== null) toBottom(el)
}}
>
<IconChevronDownOutline14 />
</button>
)}
</div>
)
}
@@ -1,13 +1,15 @@
// GenericToolCard: the registry-miss fallback toolview — classifies the tool
// into one of the five figma row variants and renders the summary row. Also
// the shared base the bash sample builds on: any ToolViewProps consumer.
// GenericToolCard: the default tool row — classifies the tool into one of
// the five figma row variants and renders the summary row. Supplied by the
// chat view as the keyed toolview slot's render-site fallback (an
// unregistered tool name lands here); registrants may also compose it as a
// base, feeding the same owner payload through.
import type { ReactNode } from 'react'
import {
IconApiOutline14, IconBrowseOutline16, IconSearchOutline16, IconThinkOutline14,
IconApiOutline14, IconBrowseOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolViewProps } from '../contract/toolview.ts'
import { toolRowModel, type ToolCallBlock, type ToolRowVariant } from '../contract/tool-call-model.ts'
import type { ToolRowOwnerProps } from '../contract/slots.ts'
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
import { ToolRow } from './ToolRow.tsx'
import { IconSparkle16 } from './IconSparkle16.tsx'
@@ -17,11 +19,13 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
search: <IconSearchOutline16 />,
read: <IconBrowseOutline16 />,
bash: <IconApiOutline14 size={16} />,
write: <IconEditOutline16 />,
edit: <IconEditOutline16 />,
others: <IconSparkle16 />,
}
export function GenericToolCard({ toolName, block, actions }: ToolViewProps) {
const model = toolRowModel(toolName, block as ToolCallBlock)
export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) {
const model = toolRowModel(toolName, block)
return (
<ToolRow
variant={model.variant}
@@ -30,7 +34,7 @@ export function GenericToolCard({ toolName, block, actions }: ToolViewProps) {
summary={model.summary}
body={model.body}
state={model.state}
onOpenDetails={actions.openDetails}
onOpenDetails={openDetails}
/>
)
}
@@ -1,18 +1,30 @@
// PendingCard: approval placeholder card. Questions take over the composer.
// PendingCard: approval/question placeholder card (visible, not answerable —
// the composer-takeover approval panel is a P-II item; wire pending semantics
// already exist so the flow must show them).
import { memo } from 'react'
import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './PendingCard.module.css'
export interface PendingCardProps {
item: PendingWait<'approval'>
item: PendingInteraction
}
export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) {
return (
<div className={css.card}>
<div className={css.title}><span className={css.mono}>{item.payload.toolName}</span></div>
{item.payload.reason !== undefined && <div className={css.reason}>{item.payload.reason}</div>}
{item.kind === 'approval' ? (
<>
<div className={css.title}><span className={css.mono}>{item.payload.toolName}</span></div>
{item.payload.reason !== undefined && <div className={css.reason}>{item.payload.reason}</div>}
</>
) : (
<>
<div className={css.title}>{item.payload.questions.length} </div>
<JsonBlock label="问题内容" payload={item.payload.questions} />
</>
)}
<div className={css.hint}>web </div>
</div>
)
@@ -1,14 +1,13 @@
// StatsLine: the session stats row (figma 122:11212 "cache hit 92% · 1,284
// tokens · 45.2s · 5 turns · 32 steps"), mounted as the chat view's
// chrome.footer — the first chrome-attachment consumer. Duration has no data
// source in P-I (ledger). Subscribes to `nodes` only: chunk batches never swap
// that reference, so the row renders zero times during streaming (the RFC
// performance model's acceptance row).
// tokens · 45.2s · 5 turns · 32 steps"), rendered by ChatView under the flow
// (part of the chat view body — the chrome attachment mechanism retired with
// the view ring). Duration has no data source in P-I (ledger). Subscribes to
// `nodes` only: chunk batches never swap that reference, so the row renders
// zero times during streaming (the RFC performance model's acceptance row).
import { memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { ChromeProps } from '../contract/views.ts'
import css from './StatsLine.module.css'
interface UsageTotals {
@@ -55,8 +54,11 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
}
}
export const StatsLine = memo(function StatsLine({ useSession }: ChromeProps) {
const nodes = (useSession as SnapshotSelectorHook<ConversationSnapshot>)((s) => s.nodes)
/** Props: the conversation-snapshot selector hook (handed down by ChatView). */
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
const nodes = useSession((s) => s.nodes)
const stats = useMemo(() => deriveStats(nodes), [nodes])
if (stats.steps === 0) return null
const parts: string[] = []
@@ -4,7 +4,7 @@
// no inline output (full results live in the details panel). Expand state is
// component-local view state; row click hands the selection off to the owner.
import { useState, type ReactNode } from 'react'
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -20,6 +20,8 @@ export interface ToolRowProps {
/** Expanded-body text; null = not expandable (leading slot never toggles). */
body: string | null
state: ToolRowState
/** Makes the row itself the expand control instead of only its leading icon. */
expandOnRowClick?: boolean | undefined
/** Selection handoff (row click), already bound to this call by the owner. */
onOpenDetails?: (() => void) | undefined
}
@@ -35,31 +37,56 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
}
}
export function ToolRow({ variant, icon, title, summary, body, state, onOpenDetails }: ToolRowProps) {
export function ToolRow({
variant,
icon,
title,
summary,
body,
state,
expandOnRowClick = false,
onOpenDetails,
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const expandable = body !== null
const open = expanded && expandable
const rowExpands = expandable && expandOnRowClick
const toggleExpand = () => {
setExpanded((v) => !v)
}
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
toggleExpand()
}
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
toggleExpand()
}
return (
<div className={css.root} data-variant={variant} data-state={state}>
<div
className={css.row}
data-clickable={onOpenDetails !== undefined || undefined}
onClick={onOpenDetails}
data-clickable={rowExpands || onOpenDetails !== undefined || undefined}
role={rowExpands ? 'button' : undefined}
tabIndex={rowExpands ? 0 : undefined}
aria-expanded={rowExpands ? open : undefined}
onClick={rowExpands ? toggleExpand : onOpenDetails}
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
>
{expandable ? (
{expandable && !rowExpands ? (
<button
type="button"
className={css.leading}
aria-expanded={open}
onClick={(e) => {
e.stopPropagation()
setExpanded((v) => !v)
}}
onClick={toggleFromLeading}
>
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
</button>
) : (
<span className={css.leading}>{leadingFor(state, icon)}</span>
<span className={css.leading}>
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
</span>
)}
<span className={css.title}>{title}</span>
{!open && (
@@ -1,80 +0,0 @@
// ToolViewOutlet: resolves the toolview for one call through ctx.toolviews
// (uSES over the registry version so unload falls back live) and renders it
// behind a per-row error boundary. GenericToolCard is the render-side
// fallback for both a registry miss and a crashed custom row. Pure props
// machinery, zero React context: a registrant inject factory receives the
// sessionId this outlet already holds, is called once per (registration x
// session) and cached, mirroring the slot injection discipline.
import { Component, useSyncExternalStore, type ReactNode } from 'react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolViewInject, ToolViewProps, ToolViewResolver } from '../contract/toolview.ts'
import { GenericToolCard } from './GenericToolCard.tsx'
export interface ToolViewOutletProps {
registry: ToolViewResolver
sessionId: SessionId
toolName: string
viewProps: ToolViewProps
}
/** Inject cache: per inject-factory (stable per registration) x session id.
* The inner Map lives and dies with its factory (WeakMap entry), so entries
* are bounded by the session count over the registration's lifetime. */
const injectCache = new WeakMap<ToolViewInject<object>, Map<SessionId, object>>()
function cachedInject(inject: ToolViewInject<object>, sessionId: SessionId): object {
let perSession = injectCache.get(inject)
if (!perSession) {
perSession = new Map()
injectCache.set(inject, perSession)
}
let props = perSession.get(sessionId)
if (!props) {
props = inject(sessionId)
perSession.set(sessionId, props)
}
return props
}
class RowErrorBoundary extends Component<
{ resetKey: unknown; fallback: ReactNode; children: ReactNode }, { failed: boolean }
> {
override state = { failed: false }
// Fallback state MUST flip here (render phase): a boundary whose derived
// state does not change re-renders the crashing children and React gives
// up after the second throw, escalating past the boundary.
static getDerivedStateFromError(): { failed: boolean } {
return { failed: true }
}
override componentDidCatch(error: unknown): void {
console.error('toolview row crashed:', error)
}
// A re-registration (resetKey bump) retries the custom row.
override componentDidUpdate(prev: { resetKey: unknown }): void {
if (this.state.failed && prev.resetKey !== this.props.resetKey) {
this.setState({ failed: false })
}
}
override render(): ReactNode {
if (this.state.failed) return this.props.fallback
return this.props.children
}
}
export function ToolViewOutlet({ registry, sessionId, toolName, viewProps }: ToolViewOutletProps) {
const version = useSyncExternalStore(
(fn) => registry.subscribe(fn),
() => registry.getVersion(),
)
const resolved = registry.resolve(toolName, sessionId)
if (resolved === undefined) return <GenericToolCard {...viewProps} />
const Row = resolved.component
return (
<RowErrorBoundary resetKey={version} fallback={<GenericToolCard {...viewProps} />}>
{resolved.inject === undefined
? <Row {...viewProps} />
: <Row {...{ ...cachedInject(resolved.inject, sessionId), ...viewProps }} />}
</RowErrorBoundary>
)
}
@@ -1,52 +0,0 @@
/**
* Chat-side registration entry, called from the plugin apply (the assembly
* point): registers the chat view with the stats-line footer chrome. The
* chat domain touches the tool ring only through the contract resolver face;
* bash sample registration moved to apply (cross-domain assembly).
*/
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationService } from '../service.ts'
import type { Translate } from '../contract/views.ts'
import type { ToolViewResolver } from '../contract/toolview.ts'
import { createChatView } from './ChatView.tsx'
import { StatsLine } from './StatsLine.tsx'
/** Read face of the sessions list store (subscription not needed: the filter
* reads the latest snapshot at each resolve). */
export interface SessionListReader { getSnapshot(): SessionListState }
/**
* Default scoped-sample filter: the sub-session family. Sub-agent rows
* rendering differently is the registry's canonical product scenario, and
* forking gives W5 acceptance a real entry point to observe the differential.
* @param list - injected sessions list read face.
* @returns filter matching sessions with a parent.
*/
export function childSessionScope(list: SessionListReader): (sessionId: SessionId) => boolean {
return sessionId => list.getSnapshot().byId[sessionId]?.parentId !== undefined
}
/** Assembly inputs for {@link registerChat} (resolved by apply, not here). */
export interface RegisterChatDeps {
conversation: ConversationService
/** Toolview read face consumed by the chat rows' outlet. */
toolviews: ToolViewResolver
/** Translator bound to the conversation namespace. */
t: Translate
}
/**
* Register the chat view (footer chrome included).
* @param deps - assembled service instances.
* @returns disposer removing the registration.
*/
export function registerChat(deps: RegisterChatDeps): () => void {
const { conversation, toolviews, t } = deps
return conversation.registerView({
id: 'chat',
label: 'Chat',
order: 0,
component: createChatView({ toolviews, t }),
chrome: { footer: StatsLine },
})
}
@@ -1,32 +1,109 @@
/**
* Slot-ring contract for the conversation package: the composed props shapes
* its registrants mount into the layout-owned slots (conversation / details /
* conversation.empty). Terminal slot design (§3): full component props are the
* automatic shares — PropsRuntime<K> (framework standard kit) & PropsStore<H>
* Slot-ring contract for the conversation package: the 'conversation.view'
* slot this package declares (the view ring — one list entry per conversation
* view tab), the chat view's per-tool row hole ('conversation.chat.toolview',
* keyed on the wire tool name), and the composed props shapes its registrants
* mount into the layout-owned slots (conversation / details /
* conversation.empty) plus its own slots. Terminal slot design (§3): full
* component props are the automatic shares — PropsRuntime<K> (framework
* standard kit) & PropsRenderSlots<S> (declared children) & PropsStore<H>
* (declared store's read/write faces) & the injected business face declared
* here. The conversation entry alone declares a child slot (the chain-kind
* conversation.composer takeover), so only ConversationSlotProps carries the
* renderSlot share.
* here.
*/
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { PendingInteraction, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { createChatStore } from '../stores.ts'
import type { SelectionTarget, ViewEntry } from './views.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
/** The shared chat store handle type (apply constructs one; conversation and details both declare it). */
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/**
* The conversation view ring: one list entry per view tab (chat here;
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
* ConversationRoot via `only: <active id>`. Declared by this package's
* 'conversation' entry (declaring is claiming). Session scope: views read
* the conversation snapshot through the standard kit.
*/
'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps }
/**
* The chat view's per-tool row hole: keyed dispatch on the wire tool name
* (the key space is runtime-open — SlotMap declares slots, never keys).
* Declared by the chat view entry (declaring is claiming); the render
* site dispatches via `entryKey: toolName` with GenericToolCard as the
* `fallback` for unregistered tools.
*/
'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps }
/**
* The composer takeover chain: entries are selector-routed replacements
* of the default InputBar. Declared by this package's 'conversation'
* entry; the owner dispatches the {@link ComposerChainProps} currency and
* routing lives in entry selectors — new takeover kinds register with
* zero owner changes.
*/
'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
}
}
/**
* View-slot owner share: deliberately empty — ConversationRoot supplies
* nothing at its renderSlot site (sessionId and the snapshot hook arrive as
* framework-standard props; tool rows go through each view's own declared
* toolview hole). Kept as the named owner seat so a future cross-view
* payload has a home.
*/
export interface ConvViewOwnerProps {}
/**
* Owner share of a per-view toolview slot: the call material the rendering
* view supplies per row. Uniform across views — the trajectory/waterfall
* toolview slots (same kind/scope/owner, names fixed by the slot-naming
* discipline) land with their own row render sites; today only the chat slot
* is declared (RendersCheck rejects a declaration nobody renders).
*/
export interface ToolRowOwnerProps {
/** Tool call identity (details linkage; stable across running → settled). */
callId: CallId
/** Wire tool name (also the keyed dispatch key at the render site). */
toolName: string
/** Frozen call slice: the running call or the settled result node. */
block: ToolCallBlock
/** Open the details panel for this call (session-level facility, supplied by the view). */
openDetails(): void
}
/**
* Full props of a registered tool-row component: the slot's runtime share
* (owner payload + session standard kit + global seat). Registrants type
* their component `FC<ToolRowProps & I>` with `I` inferred from their inject
* factory. Declared against the chat slot; the three per-view toolview slots
* share one declaration shape, so this alias serves them all.
*/
export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'>
/**
* Base props of a conversation view entry: the framework standard kit for the
* session-scope 'conversation.view' slot (useSession narrowed to the
* conversation snapshot by the runtime merge, sessionId, useSessions).
* Entries declaring the shared store or an inject face compose their shares
* on top (the chat entry's {@link ChatViewSlotProps}); store-less pure
* readers (ui-trajectory) take this base alone.
*/
export type ConvViewProps = PropsRuntime<'conversation.view'>
/** The shared chat store handle type (apply constructs one; the conversation, details, and chat-view registrations all declare it). */
export type ChatStore = ReturnType<typeof createChatStore>
/**
* Injected share of the conversation slot: plain data and callbacks only
* (design §5 — hooks are framework-made). The store lines that used to ride
* here live in the declared {@link ChatStore} now; ancestry derives from the
* standard useSessions hook in-component; view rendering moved into the
* component, which holds every share a view needs.
* here live in the declared {@link ChatStore}; ancestry derives from the
* standard useSessions hook in-component; views render through the declared
* 'conversation.view' child slot, with this face projecting the tab strip.
*/
export interface ConversationInjected {
/** View registry read face (uSES triple from the conversation service). */
/** View tab read face (uSES triple over the 'conversation.view' slot ledger). */
views: {
list(): readonly ViewEntry[]
list(): readonly ViewTab[]
subscribe(fn: () => void): () => void
version(): number
}
@@ -34,10 +111,6 @@ export interface ConversationInjected {
send(text: string, mode: 'queue' | 'steer'): void
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
stop(): void
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
openDetails(target: SelectionTarget): void
/** Pull one older history page. */
loadOlder(): void
/** Navigate to another session (breadcrumb ancestors). */
open(id: SessionId): void
}
@@ -54,11 +127,27 @@ export interface ComposerChainProps {
interactions: readonly PendingInteraction[]
}
/** Full conversation-slot component props: runtime share & child-render share & store share & injected share. */
/** Full conversation-slot component props: runtime share & child-render share (view ring + composer chain) & store share & injected share. */
export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.composer'>
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'>
& PropsStore<ChatStore> & ConversationInjected
/**
* Injected share of the chat view entry: the two callbacks whose targets live
* outside the view (layout orchestration; the session object layer).
*/
export interface ChatViewInjected {
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
openDetails(target: SelectionTarget): void
/** Pull one older history page. */
loadOlder(): void
}
/** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */
export type ChatViewSlotProps =
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview'>
& PropsStore<ChatStore> & ChatViewInjected
/**
* Injected share of the details slot: the panel is otherwise a pure reader of
* the shared chat store, but its close button is a layout orchestration call.
@@ -3,25 +3,29 @@
* one-line summary and expanded-body text from the frozen call slice. No
* inline output ever — full results live in the details panel.
*/
import type { ToolCallBlock } from './toolview.ts'
// The block union's defining home is runtime (fold-product types); this
// contract only forwards it (type-definition authority stays with the layer
// that produces the values).
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
export type { ToolCallBlock } from './toolview.ts'
export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
/** The frozen slice the chat view hands to toolview components as `block`
* (both members are cache-stable references off ConversationSnapshot). */
/** The five figma row variants (think is fed by reasoning blocks, not tool calls). */
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'others'
/** The seven row variants (think is fed by reasoning blocks, not tool calls). */
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'others'
/** Row state semantic; colors self-supplied via StateDot (design gives none). */
export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped'
/** Figma row titles per variant (design literals, not translatable copy). */
export const VARIANT_TITLES: Record<ToolRowVariant, string> = {
think: 'Think', search: 'Search', read: 'Read', bash: 'Bash', others: 'Tool call',
think: 'Think', search: 'Search', read: 'Read', bash: 'Bash',
write: 'Write', edit: 'Edit', others: 'Tool call',
}
/** Known tool name -> variant; fs write/edit intentionally fall to others (no figma form). */
/** Known tool name -> variant. */
const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
bash: 'bash',
read: 'read',
@@ -29,6 +33,8 @@ const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
web_search: 'search',
grep: 'search',
glob: 'search',
write: 'write',
edit: 'edit',
}
/**
@@ -78,6 +84,8 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
read: ['path', 'file_path', 'url'],
search: ['query', 'pattern', 'url'],
think: [],
write: ['path', 'file_path'],
edit: ['path', 'file_path'],
others: [],
}
@@ -1,78 +0,0 @@
/**
* Tool-ring contract: the props surface handed to toolview components, the
* registry's resolve/registration shapes, and the tool-call block union.
* Shared face between the chat domain (ToolViewOutlet consumes resolve) and
* the toolviews domain (registry implementation + sample rows); domain
* implementation files import this, never each other.
*/
import type { FC } from 'react'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { CallId, Translate } from './views.ts'
// The block union's defining home is runtime (fold-product types); the
// contract only forwards it (type-definition authority stays with the layer
// that produces the values).
export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
/** Props handed to registered toolview components. */
export interface ToolViewProps {
callId: CallId
toolName: string
block: ToolCallBlock
useSession: UseSession
actions: { openDetails(): void }
t: Translate
}
/**
* Toolview inject factory: produces the registrant's private injected share
* `I`, called once per (registration x session) and cached by the render
* outlet. Mirrors the slot inject shape (parameters derive from the
* declaration): toolviews are session-domain by nature, so the factory
* receives the session id only — service access goes through the
* registrant's own apply-closure ctx (design §5; binding objects retired).
*/
export type ToolViewInject<I extends object> = (sessionId: SessionId) => I
/** Options accepted by the toolview registry's register; `I` is inferred from the inject factory. */
export interface ToolViewOptions<I extends object = object> {
/** Session filter; absent = global registration. */
scope?: (sessionId: SessionId) => boolean
/** Private inject factory merged into the row's props by the render outlet. */
inject?: ToolViewInject<I>
}
/**
* A resolved toolview registration. `I` is erased to `object` on the resolve
* read face (storage erases the per-registration parameter; the outlet merges
* injected props untyped — the register site already proved component ⊇ I).
*/
export interface ResolvedToolView<I extends object = object> {
component: FC<ToolViewProps & I>
inject?: ToolViewInject<I>
}
/** The registry's read face consumed by render outlets (implementation lives in the toolviews domain). */
export interface ToolViewResolver {
/**
* Resolve the renderer for a tool in a session. Order: scope match (later
* registration wins) > global > undefined (caller falls back to the
* generic card).
* @param tool - tool name.
* @param sessionId - session the row renders in.
* @returns resolved view, or undefined when nothing matches.
*/
resolve(tool: string, sessionId: SessionId): ResolvedToolView | undefined
/**
* Subscribe to registration changes (synchronous).
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void
/**
* Monotonic version for uSES pairing.
* @returns current version.
*/
getVersion(): number
}
@@ -1,89 +1,39 @@
/**
* View-ring contract: the typed conversation view table, the chat store state
* shared through it, and the props surfaces handed to registered views.
* Shared face between the skeleton domain (ConversationRoot renders views)
* and the chat domain (registers the chat view); domain implementation files
* import this, never each other.
* Shared conversation contract primitives: the view tab projection (slot
* entries in 'conversation.view' surface as tabs), the chat store state
* shared through the declared store, and the selection primitives every
* domain consumes. Shared face between the skeleton domain (tab strip +
* view outlet) and the chat domain; domain implementation files import this,
* never each other. The view ring itself IS the 'conversation.view' slot
* (contract in slots.ts) — the package-local view registry is retired, and
* so is the hand-threaded translate channel (framework-level per-slot i18n
* injection is the planned replacement).
*/
import type { FC } from 'react'
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
/**
* One ConversationViewMap entry: per-view props extension shapes (design
* ledger, view ring). `chromeProps` extends {@link ChromeProps} for the
* view's chrome attachments; `extraProps` extends {@link ConvViewProps} for
* the view component itself. Both optional — the common bases stay the floor.
*/
export interface ViewEntryDef { chromeProps?: object; extraProps?: object }
/**
* Typed conversation view table; ui-trajectory merges {trajectory, waterfall}.
* The chat entry is declared inline here (self-merge from a sibling module
* trips TS6305 under tsc -b).
*/
export interface ConversationViewMap { chat: ViewEntryDef }
/** View id constrained to registered ConversationViewMap keys (all string literals; chat is declared inline). */
export type ViewId = keyof ConversationViewMap
/** Per-view chrome props: the common base plus the entry's declared extension. */
export type ChromePropsOf<Id extends ViewId> =
ChromeProps & (ConversationViewMap[Id] extends { chromeProps: infer C extends object } ? C : object)
/** Per-view component props: the common base plus the entry's declared extension. */
export type ConvViewPropsOf<Id extends ViewId> =
ConvViewProps & (ConversationViewMap[Id] extends { extraProps: infer E extends object } ? E : object)
/** Tool call identity as carried on the wire (branded upstream in connection). */
export type CallId = string
/** Translate function bound to a namespace via i18n. */
export type Translate = (key: string, params?: Record<string, unknown>) => string
/** One registered conversation view (props positions keyed by the entry's declared shapes). */
export interface ViewEntry<Id extends ViewId = ViewId> {
id: Id
label: string
order?: number
component: FC<ConvViewPropsOf<Id>>
/** Per-view chrome attachments (chat mounts the stats line as footer). */
chrome?: { header?: FC<ChromePropsOf<Id>>; footer?: FC<ChromePropsOf<Id>> }
}
/** Props for view chrome attachments. */
export interface ChromeProps { sessionId: SessionId; useSession: UseSession }
/** Selection target for the details linkage channel (toolcall is the step special case). */
export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: CallId; toolName?: string }
/**
* One conversation view tab, projected from a 'conversation.view' slot
* entry's registration options (label falls back to the entry id).
*/
export interface ViewTab { id: string; label: string }
/**
* Chat store state (slot terminal design §4): the per-session store shared by
* the conversation and details registrations. `createChatStore` implements
* this shape; views read it through {@link ConvViewProps}'s pass-through hook.
* `view` may carry a stale persisted id after a view plugin unloads — the
* registry is the runtime validator (unknown ids fall back to the first view).
* the conversation, chat-view, and details registrations. `createChatStore`
* implements this shape. `view` may carry a stale persisted id after a view
* plugin unloads — the slot ledger is the runtime validator (unknown ids fall
* back to the first registered view).
*/
export interface ChatStoreState {
/** Details-linkage channel (conversation writes, details reads). */
selection: SelectionTarget | null
/** Composer draft (persisted; survives session switches and reloads). */
draft: string
/** Active conversation view id; null falls back to the first registered view. */
view: ViewId | null
}
/**
* Props handed to registered conversation views. `useSession` and `useStore`
* are the framework hooks ConversationRoot received as a slot registrant,
* passed through unchanged (hook transfer is plain props passing; no
* business-made subscription exists on this path). No renderSlot share: the
* view ring delegates no sub-slots.
*/
export interface ConvViewProps {
sessionId: SessionId
useSession: UseSession
/** Chat store read face (selection is the only slice views consume today). */
useStore: SnapshotSelectorHook<ChatStoreState>
actions: { openDetails(t: SelectionTarget): void; loadOlder(): void }
/** Active conversation view id ('conversation.view' entry id); null falls back to the first view. */
view: string | null
}
@@ -1,45 +1,31 @@
/**
* Conversation domain plugin, browser half: skeleton (header/tabs/composer),
* typed view registry, scope-addressed ConversationService, named toolview
* registry, minimal details panel. Contract: api-contracts v3 section 7.
* Thin shell: type surfaces live in contract/, assembly in apply.ts; the
* three implementation domains (skeleton/chat/toolviews) never import each
* other — contract/ is their only shared face.
* the 'conversation.view' slot ring (chat entry here; other plugins
* contribute view tabs through ctx.slots), the chat view's keyed
* 'conversation.chat.toolview' row hole, scope-addressed ConversationService,
* minimal details panel. Contract: api-contracts v3 section 7. Thin shell:
* type surfaces live in contract/, assembly in apply.ts; the implementation
* domains (skeleton/chat) never import each other — contract/ is their only
* shared face.
*/
import type { ConversationService } from './service.ts'
import type { ToolViewRegistry } from './toolviews/registry.ts'
import type { ComposerChainProps } from './contract/slots.ts'
export { apply, inject } from './apply.ts'
export { ConversationService } from './service.ts'
export { ToolViewRegistry } from './toolviews/registry.ts'
export type {
CallId, ChatStoreState, ChromeProps, ChromePropsOf, ConversationViewMap, ConvViewProps,
ConvViewPropsOf, SelectionTarget, Translate, ViewEntry, ViewEntryDef, ViewId,
CallId, ChatStoreState, SelectionTarget, ViewTab,
} from './contract/views.ts'
export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type {
ResolvedToolView, ToolCallBlock, ToolViewOptions, ToolViewProps, ToolViewResolver,
} from './contract/toolview.ts'
export type {
ChatStore, ComposerChainProps, ConversationInjected, ConversationSlotProps, DetailsInjected,
DetailsSlotProps, EmptyStateInjected, EmptyStateSlotProps,
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ConversationInjected,
ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.
declare module 'cordis' {
interface Context {
conversation: ConversationService
toolviews: ToolViewRegistry
}
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'conversation.composer': {
kind: 'chain'
scope: 'session'
owner: ComposerChainProps
}
}
}
@@ -1,10 +1,10 @@
/**
* ConversationService implementation: scope-addressed send/cancel, view
* registry with a uSES read face, and the empty-state startSession chain.
* Contract: api-contracts v3 section 7. Selection/draft state moved to the
* declared chat store (slot terminal design §4) — the per-scope store maps,
* lazy construction, and prune bookkeeping this service used to carry are
* retired; what remains is the send/stop orchestration face.
* ConversationService implementation: scope-addressed send/cancel and the
* empty-state startSession chain. Contract: api-contracts v3 section 7.
* Selection/draft state moved to the declared chat store (slot terminal
* design §4); the view registry moved to the 'conversation.view' slot (slot
* ledger owns registration, ordering, and disposal) — what remains is the
* send/stop orchestration face.
*
* Scope addressing rides the cordis Service tracker: property access through
* `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods
@@ -23,23 +23,9 @@ import type { Context } from 'cordis'
// in the browser while unit tests (single-instance path resolution) stay green.
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { Session, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ViewEntry, ViewId } from './index.ts'
/** Mutable view-registry cell (plain object: mutation never crosses the tracker proxy). */
interface ViewsState {
entries: Map<string, ViewEntry>
/** Sorted projection cache; null = rebuild on next read. */
cache: readonly ViewEntry[] | null
tick: number
listeners: Set<() => void>
}
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
export class ConversationService extends Service {
private readonly viewsState: ViewsState = {
entries: new Map(), cache: null, tick: 0, listeners: new Set(),
}
/**
* @param ctx - owning root context (the plugin apply context; the service
* registers itself and follows that fiber's lifetime).
@@ -68,60 +54,6 @@ export class ConversationService extends Service {
if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Register a conversation view. Duplicate ids throw; the registration is an
* effect on the caller's fiber (plugin unload collects it).
* @param entry - the view entry.
* @returns disposer removing the view.
*/
registerView<Id extends ViewId>(entry: ViewEntry<Id>): () => void {
const views = this.viewsState
const dispose = this.ctx.effect(() => {
if (views.entries.has(entry.id)) {
throw new Error(`conversation view "${entry.id}" is already registered`)
}
views.entries.set(entry.id, entry)
bumpViews(views)
return () => {
views.entries.delete(entry.id)
bumpViews(views)
}
}, 'conversation.registerView()')
// The effect disposer settles asynchronously; the registry face stays a
// synchronous fire-and-forget disposer.
return () => { void dispose() }
}
/**
* Registered views ordered by `order` (ties keep registration sequence).
* Stable array reference between mutations (uSES getSnapshot source).
* @returns the view entries.
*/
views(): readonly ViewEntry[] {
const state = this.viewsState
state.cache ??= [...state.entries.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
return state.cache
}
/**
* Subscribe to view registry changes (synchronous, like the toolview registry).
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribeViews(fn: () => void): () => void {
const { listeners } = this.viewsState
listeners.add(fn)
return () => { listeners.delete(fn) }
}
/**
* Monotonic view registry version for uSES pairing.
* @returns current version.
*/
viewsVersion(): number {
return this.viewsState.tick
}
/**
* Empty-state first-send chain (root-context method; does not read scope):
* create the session, navigate to it, then send through the new scope.
@@ -167,9 +99,3 @@ export class ConversationService extends Service {
return sessions
}
}
function bumpViews(state: ViewsState): void {
state.cache = null
state.tick += 1
for (const fn of [...state.listeners]) fn()
}
@@ -1,16 +1,18 @@
// ConversationRoot: the conversation slot's skeleton (figma Header 39:27730 +
// Tab_Group + view area + composer). Pure component — everything arrives via
// props: the framework standard kit (useSession/sessionId/useSessions), the
// declared chat store's useStore/actions, and the injected business face.
// declared chat store's useStore/actions, the injected business face, and the
// renderSlot share for the declared 'conversation.view' child slot (views are
// slot entries; the active one renders via the list `only` filter) plus the
// renderSlotChain share for the 'conversation.composer' takeover chain.
// Breadcrumbs derive from useSessions with a pure parentId walk; the active
// view id lives in the chat store's `view` field (per-session by store scope).
import { useMemo, useSyncExternalStore, type ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps } from '../contract/slots.ts'
import type { ConvViewProps, ViewEntry } from '../contract/views.ts'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import css from './ConversationRoot.module.css'
@@ -35,15 +37,15 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
}
export function ConversationRoot({
sessionId, useSession, useSessions, useStore, actions,
views, send, stop, openDetails, loadOlder, open, renderSlotChain,
sessionId, useSession, useSessions, useStore, actions, renderSlot, renderSlotChain,
views, send, stop, open,
}: ConversationRootProps) {
useSyncExternalStore(views.subscribe, views.version)
const list = views.list()
const tabs = views.list()
// The store's persisted view id may be stale (view plugin unloaded); the
// registry is the runtime validator — unknown ids fall to the first view.
// slot ledger is the runtime validator — unknown ids fall to the first view.
const activeId = useStore(s => s.view) ?? 'chat'
const active = list.find(v => v.id === activeId) ?? list[0]
const active = tabs.find(v => v.id === activeId) ?? tabs[0]
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
const draft = useStore(s => s.draft)
@@ -57,27 +59,6 @@ export function ConversationRoot({
? null
: { op: promptError.op, message: `${promptError.error.message}${promptError.error.code}` }
// Views receive the shares this component already holds (hook transfer is
// plain props passing); the callback slice is referentially stable per
// injected identity so memoized view rows hold.
const viewProps = useMemo<ConvViewProps>(() => ({
sessionId, useSession, useStore,
actions: { openDetails, loadOlder },
}), [sessionId, useSession, useStore, openDetails, loadOlder])
const renderView = (entry: ViewEntry): ReactNode => {
const Header = entry.chrome?.header
const Footer = entry.chrome?.footer
const View = entry.component
return (
<>
{Header !== undefined && <Header sessionId={sessionId} useSession={useSession} />}
<View {...viewProps} />
{Footer !== undefined && <Footer sessionId={sessionId} useSession={useSession} />}
</>
)
}
// The default composer doubles as the chain's all-decline fallback: a
// pending wait with no registered takeover must still leave the input usable.
const composerBar = (
@@ -120,9 +101,9 @@ export function ConversationRoot({
{/* Header button row (Fork / Session log / I/O Details): a P-I visual
placeholder registry slot is deferred — buttons land with their features. */}
</div>
{list.length > 1 && (
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">
{list.map(v => (
{tabs.map(v => (
<button
key={v.id}
type="button"
@@ -139,7 +120,7 @@ export function ConversationRoot({
</header>
<div className={css.viewArea}>
{active !== undefined && renderView(active)}
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div>
{renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })}
@@ -10,7 +10,7 @@
* in the module cache (a de-facto singleton surviving plugin reloads).
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatStoreState, SelectionTarget, ViewId } from './contract/views.ts'
import type { ChatStoreState, SelectionTarget } from './contract/views.ts'
/**
* Annotation twin of the actions literal below (the export needs a declared
@@ -21,22 +21,22 @@ type ChatActions = {
setDraft: (draft: ChatStoreState, text: string) => void
clearDraft: (draft: ChatStoreState) => void
restoreDraft: (draft: ChatStoreState, text: string) => void
setView: (draft: ChatStoreState, view: ViewId) => void
setView: (draft: ChatStoreState, view: string) => void
}
/**
* Declare the per-session chat store. `selection` is the details-linkage
* channel (conversation writes, details reads); `draft` is the composer text
* (persisted so it survives session switches and reloads); `view` is the
* active conversation view id (previously layout.viewFor — store seat is the
* cross-remount survival channel, null falls back to the first registered view).
* active conversation view id (a 'conversation.view' entry id — store seat is
* the cross-remount survival channel, null falls back to the first view).
* @returns the store handle (spec + identity + factory in one value).
*/
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
return defineStore({
// Anchored to the contract shape: views consume the store through
// ConvViewProps' SnapshotSelectorHook<ChatStoreState>, so init and the
// contract cannot drift.
// Anchored to the contract shape: consumers read the store through
// PropsStore<ChatStore>'s SnapshotSelectorHook<ChatStoreState>, so init
// and the contract cannot drift.
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
persist: 'dsh.conversation.chat',
actions: {
@@ -46,7 +46,7 @@ export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions
// Optimistic-send failure restore: only when the user typed nothing new
// since the clear (send choreography lives in the inject factory).
restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text },
setView: (d, view: ViewId) => { d.view = view },
setView: (d, view: string) => { d.view = view },
},
})
}
@@ -1,20 +1,32 @@
// Bash toolview sample, written in third-party posture: everything below uses
// only the public registration surface (ctx.toolviews.register + ToolViewProps)
// — the differential-rendering acceptance proof for the registry chain.
// Two registrations: a global bash row, and a scope-filtered variant that
// takes over for matching sessions only (later registration wins its tier).
// only the public slot surface (ctx.slots.register into the keyed
// 'conversation.chat.toolview' hole + ToolRowProps) — the acceptance proof
// that a plain plugin can take over a tool row with zero dedicated machinery.
// Session-dimension differentiation happens INSIDE the component (the
// canonical sub-agent scenario): rows in child sessions render the scoped
// variant, derived from the standard useSessions kit — no registry predicates.
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolViewProps } from '../contract/toolview.ts'
import type { ToolViewRegistry } from './registry.ts'
import { toolRowModel, type ToolCallBlock } from '../contract/tool-call-model.ts'
import type { Context } from 'cordis'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import css from './bash-sample.module.css'
/** Global bash row: command-first monospace summary (replaces the generic row). */
export function BashRow({ toolName, block, actions }: ToolViewProps) {
const model = toolRowModel(toolName, block as ToolCallBlock)
/** Bash row: command-first monospace summary replacing the generic card.
* Sub-session rows (parentId present) swap the prompt for a scoped badge —
* the differential stays observable per session from one registration. */
export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
if (isChild) {
return (
<div className={css.row} data-sample="bash-scoped" onClick={openDetails}>
<span className={css.scopeBadge}>scoped</span>
<span className={css.command}>{model.summary}</span>
</div>
)
}
return (
<div className={css.row} data-sample="bash-global" onClick={actions.openDetails}>
<div className={css.row} data-sample="bash-global" onClick={openDetails}>
<span className={css.prompt} aria-hidden>$</span>
<span className={css.command}>{model.summary}</span>
{model.state === 'error' && <span className={css.err}>failed</span>}
@@ -22,31 +34,20 @@ export function BashRow({ toolName, block, actions }: ToolViewProps) {
)
}
/** Scoped variant: visually distinct so the differential hit is observable. */
export function ScopedBashRow({ toolName, block, actions }: ToolViewProps) {
const model = toolRowModel(toolName, block as ToolCallBlock)
return (
<div className={css.row} data-sample="bash-scoped" onClick={actions.openDetails}>
<span className={css.scopeBadge}>scoped</span>
<span className={css.command}>{model.summary}</span>
</div>
)
}
/**
* Register both sample rows.
* @param toolviews - the conversation plugin's registry service.
* @param scope - session filter for the scoped variant.
* @returns disposer removing both registrations.
* The sample as a plain registrant plugin. `inject` carries the load-order
* seam: requiring the conversation service guarantees the chat entry (and
* with it the 'conversation.chat.toolview' declaration) is registered —
* ui-conversation's apply mounts the service after the chat entry.
*/
export function registerBashSamples(
toolviews: ToolViewRegistry,
scope: (sessionId: SessionId) => boolean,
): () => void {
const offGlobal = toolviews.register('bash', BashRow)
const offScoped = toolviews.register('bash', ScopedBashRow, { scope })
return () => {
offGlobal()
offScoped()
}
export const bashToolviewSample = {
name: 'bash-toolview-sample',
inject: ['slots', 'conversation'],
/**
* Register the bash row into the chat view's keyed toolview hole.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'bash' }, BashRow)
},
}
@@ -1,103 +0,0 @@
/**
* ToolViewRegistry: named per-tool component registry, session-scope aware
* (api-contracts v3 section 7). Consumed by chat now, trajectory/waterfall
* later — deliberately a named service, not a SlotMap key. The tool key set
* is deliberately open (model-side tools arrive at runtime): the strong
* typing lives inside the Entry — `I` is inferred from the inject factory at
* the register site and proves component props ⊇ ToolViewProps & I.
*/
import type { FC } from 'react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ResolvedToolView, ToolViewOptions, ToolViewProps } from '../contract/toolview.ts'
/** Stored registration: the per-registration inject parameter is erased
* (storage-erase/read-restore is the typed-Map boundary, one cast budgeted). */
interface Registration extends ToolViewOptions {
component: FC<ToolViewProps & object>
}
/**
* Per-tool renderer registry. Resolution order: scope match (later
* registration wins) > global (same tie-break) > undefined, where the caller
* falls back to GenericToolCard.
*/
export class ToolViewRegistry {
private byTool = new Map<string, Registration[]>()
private version = 0
private listeners = new Set<() => void>()
/**
* Register a tool row renderer. The component must accept the shared
* ToolViewProps plus its own injected share `I` — mismatches (missing keys,
* wrong types, an inject factory that does not produce what the component
* declares) are register-site compile errors.
* @param tool - tool name the renderer takes over.
* @param component - row component over ToolViewProps & I.
* @param opts - optional session-scope filter and private inject factory.
* @returns disposer removing this registration.
*/
register<I extends object = object>(
tool: string, component: FC<ToolViewProps & I>, opts?: ToolViewOptions<I>): () => void {
const list = this.byTool.get(tool) ?? []
if (list.length === 0) this.byTool.set(tool, list)
// Storage erases I (heterogeneous registrations share one list); resolve
// restores the erased shape on the read face.
const entry: Registration = { component: component as FC<ToolViewProps & object>, ...opts }
list.push(entry)
this.bump()
let disposed = false
return () => {
if (disposed) return
disposed = true
const at = list.indexOf(entry)
/* v8 ignore next -- negative arm: an entry lives in one list and only its
own once-guarded disposer removes it, so a live disposer always finds it. */
if (at >= 0) list.splice(at, 1)
if (list.length === 0) this.byTool.delete(tool)
this.bump()
}
}
/**
* Resolve the renderer for a tool in a session.
* @param tool - tool name.
* @param sessionId - session the row renders in (fed to scope filters).
* @returns resolved view, or undefined when nothing matches.
*/
resolve(tool: string, sessionId: SessionId): ResolvedToolView | undefined {
const list = this.byTool.get(tool)
if (list === undefined) return undefined
let global: Registration | undefined
let scoped: Registration | undefined
for (const entry of list) {
if (entry.scope === undefined) global = entry
else if (entry.scope(sessionId)) scoped = entry
}
const hit = scoped ?? global
if (hit === undefined) return undefined
return hit.inject === undefined ? { component: hit.component } : { component: hit.component, inject: hit.inject }
}
/**
* Subscribe to registration changes (render outlets re-resolve on notify).
* @param fn - change listener.
* @returns disposer.
*/
subscribe(fn: () => void): () => void {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
/**
* Monotonic registration version for uSES getSnapshot.
* @returns current version.
*/
getVersion(): number {
return this.version
}
private bump(): void {
this.version += 1
for (const fn of this.listeners) fn()
}
}
@@ -15,11 +15,10 @@ export const name = 'client-ui-conversation-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the conversation service emits no cordis events — its
* view and toolview registries notify through package-local subscribe faces
* whose ordering (synchronous version bump before notification) is exercised
* directly by the behavior specs, and the per-scope store accounts are owned
* mutable state with no cross-plugin observer to contradict.
* No runtime invariant: the conversation service emits no cordis events, and
* both rings this package owns (the 'conversation.view' tab ring and the
* 'conversation.chat.toolview' row hole) ride the slot system, whose ledger
* invariants live with the runtime slots package.
*/
const install: InvariantInstaller = () => {}
@@ -2,10 +2,12 @@
// apply inject factories exercised end to end against the terminal thin
// shape: the conversation surface (views triple, send choreography incl.
// optimistic clear + failure restore THROUGH the declared store actions,
// openDetails = select action + layout orchestration, watch-driven open,
// sessions.open navigation), the injectless-but-closeDetails details surface,
// and the one-callback empty surface. Complements chat-apply.spec.tsx
// (registration) and selection-survival.spec.ts (store axis).
// openDetails = select action + layout orchestration, sessions.open
// navigation), the injectless-but-closeDetails details surface, and the
// one-callback empty surface. Complements chat-apply.spec.tsx (registration)
// and selection-survival.spec.ts (store axis). History opening is NOT an
// inject concern anymore — the runtime sessions service opens on watch
// (sessions-service.spec.ts owns that behavior).
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -13,10 +15,10 @@ import { cleanup } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
import { ConversationService, apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ConversationInjected, DetailsInjected, EmptyStateInjected,
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { createChatStore } from '../src/client/stores.ts'
@@ -103,7 +105,7 @@ async function bench() {
slots.install({ renderRoot: (h) => { host = h; return null } })
slots.renderSlot('root', {})
const hostFace = host!
const entryOf = (key: 'conversation' | 'details' | 'conversation.empty') => hostFace.entriesOf(key)[0]!
const entryOf = (key: 'conversation' | 'conversation.view' | 'details' | 'conversation.empty') => hostFace.entriesOf(key)[0]!
/** Resolve store instance + call the inject the way the outlet would. */
const conversationSurface = (id: SessionId) => {
const entry = entryOf('conversation')
@@ -112,18 +114,30 @@ async function bench() {
id, instance.actions)
return { instance, injected }
}
return { ctx, slots, hostFace, entryOf, conversationSurface, sessionFake, sessionsFake, layoutFake, mint }
/** Same resolution for the chat entry riding the view ring. */
const chatViewSurface = (id: SessionId) => {
const entry = entryOf('conversation.view')
const instance = hostFace.storeOf(entry, id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)(
id, instance.actions)
return { instance, injected }
}
return { ctx, slots, hostFace, entryOf, conversationSurface, chatViewSurface, sessionFake, sessionsFake, layoutFake, mint }
}
describe('conversation slot inject surface', () => {
it('assembles the thin surface, pulls history through the watch signal, navigates via sessions.open', async () => {
it('assembles the thin surface side-effect-free, navigates via sessions.open', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
expect(b.sessionFake.open).toHaveBeenCalledTimes(1)
// Assembly has no session side effects: opening the event window belongs
// to the runtime watch path, not the inject factory.
expect(b.sessionFake.open).not.toHaveBeenCalled()
expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
injected.open(ROOT)
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
injected.loadOlder()
// loadOlder moved to the chat view entry's face (the ring rider).
const chatView = b.chatViewSurface(ROOT)
chatView.injected.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
})
@@ -161,27 +175,51 @@ describe('conversation slot inject surface', () => {
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
})
it('openDetails writes the selection through the store actions and opens the panel', async () => {
it('inject fails loud when the session resolves no scope or the scope lacks the service', async () => {
const b = await bench()
const { instance, injected } = b.conversationSurface(ROOT)
const entry = b.entryOf('conversation')
const instance = b.hostFace.storeOf(entry, ROOT) as ChatInstance
const injectFn = entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationInjected
// Unknown session: sessions.scope answers nothing.
;(b.sessionsFake.scope as unknown) = () => undefined
expect(() => injectFn(ROOT, instance.actions)).toThrow(/resolved no scope/)
// A scope minted outside the service tree: no conversation service on it.
const foreign = new Context()
;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({})
expect(() => injectFn(ROOT, instance.actions)).toThrow(/unavailable through the session scope/)
})
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
const b = await bench()
const { instance, injected } = b.chatViewSurface(ROOT)
injected.openDetails({ turnSeq: 2, callId: 'c1' })
expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' })
expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
// The chat view shares the conversation entry's store instance: selection
// writes land where the skeleton and details read.
const conv = b.conversationSurface(ROOT)
expect(conv.instance).toBe(instance)
})
it('views read face forwards to the service registry (subscribe/version)', async () => {
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
const before = injected.views.version()
const listener = vi.fn()
const unsub = injected.views.subscribe(listener)
const conversation = b.ctx.get('conversation') as
import('@deepseek-ai/dsh-client-ui-conversation/client').ConversationService
const off = conversation.registerView({ id: 'chat2', label: 'X', component: () => null } as never)
// A second ring rider (what ui-trajectory does in production).
const off = b.slots.register(
{ name: 'conversation.view', id: 'chat2', order: 5, label: 'X' } as never, (() => null) as never)
await Promise.resolve() // ledger notifications batch per microtask
expect(listener).toHaveBeenCalled()
expect(injected.views.version()).toBeGreaterThan(before)
expect(injected.views.list().map(v => v.id)).toEqual(['chat', 'chat2'])
// Label falls back to the id when a rider declares none.
const off2 = b.slots.register(
{ name: 'conversation.view', id: 'bare', order: 6 } as never, (() => null) as never)
expect(injected.views.list().map(v => v.label)).toEqual(['Chat', 'X', 'bare'])
off()
off2()
unsub()
})
})
@@ -211,4 +249,14 @@ describe('details and empty inject surfaces', () => {
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue')
})
it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => {
const b = await bench()
const injected = (b.entryOf('conversation.empty').inject as unknown as () => EmptyStateInjected)()
// Tear the service's own fiber (registry keyed by the class): the slot
// entries survive, so the gesture-time read hits the loud branch.
b.ctx.registry.delete(ConversationService)
await vi.waitFor(() => { expect(b.ctx.get('conversation')).toBeUndefined() })
expect(() => injected.startSession({ text: 'go', mode: 'queue' })).toThrow(/conversation service unavailable/)
})
})
@@ -1,9 +1,11 @@
// @vitest-environment jsdom
// apply wiring: services provided, chat view + footer chrome registered, the
// three slot registrations land against a root entry's children declarations
// (the AppFrame role), the shared store handle rides both session slots, and
// the bash samples resolve differentially (sub-session default scope).
// Full-chain rendering belongs to the shell e2e; this spec stops at the
// apply wiring: the conversation service provided, the chat view registered
// as the first 'conversation.view' ring entry declaring the keyed toolview
// hole, the three slot registrations land against a root entry's children
// declarations (the AppFrame role), the shared store handle rides all session
// entries, and the bash sample mounts through the load-order seam as a keyed
// entry. Full-chain rendering belongs to the machinery spec
// (chat-toolview-slot.spec.tsx) and the shell e2e; this spec stops at the
// assembly surface.
import { Context } from 'cordis'
@@ -11,8 +13,7 @@ import { describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
const ROOT = 'root-1' as SessionId
const CHILD = 'child-1' as SessionId
@@ -60,62 +61,69 @@ async function bench() {
}
/** First stored entry for a key (inject/store live directly on StoredEntry). */
function renderEntryOf(slots: SlotsService, key: 'conversation' | 'details' | 'conversation.empty') {
function renderEntryOf(slots: SlotsService, key: 'conversation' | 'conversation.view' | 'details' | 'conversation.empty') {
return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
}
describe('apply wiring', () => {
it('provides conversation and toolviews services', async () => {
it('provides the conversation service', async () => {
const b = await bench()
await b.fiber.await()
expect(b.ctx.get('conversation')).toBeDefined()
expect(b.ctx.get('toolviews')).toBeInstanceOf(ToolViewRegistry)
})
it('registers the chat view with the stats footer', async () => {
it('registers the chat view as the first ring entry, declaring the keyed toolview hole', async () => {
const b = await bench()
await b.fiber.await()
const conversation = b.ctx.get('conversation') as ConversationService
const views = conversation.views()
expect(views.map((v) => v.id)).toEqual(['chat'])
expect(views[0]?.chrome?.footer).toBeDefined()
const entries = b.slots.entries('conversation.view')
expect(entries.map((e) => e.options.id)).toEqual(['chat'])
expect(entries[0]?.options.label).toBe('Chat')
expect(entries[0]?.options.order).toBe(0)
// Declaring is claiming: the chat entry's registration put the hole on
// the ledger with the contract's kind/scope.
expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
})
it('occupies the three slots; session pair shares one store handle, empty declares none', async () => {
it('occupies the three slots + the ring; session entries share one store handle, empty declares none', async () => {
const b = await bench()
await b.fiber.await()
const conversation = renderEntryOf(b.slots, 'conversation')
const chatView = renderEntryOf(b.slots, 'conversation.view')
const details = renderEntryOf(b.slots, 'details')
const empty = renderEntryOf(b.slots, 'conversation.empty')
expect(conversation?.inject).toBeTypeOf('function')
expect(chatView?.inject).toBeTypeOf('function')
expect(details?.inject).toBeTypeOf('function')
expect(empty?.inject).toBeTypeOf('function')
// The shared handle: one apply-built store value on BOTH session entries.
// The shared handle: one apply-built store value on ALL session entries.
expect(conversation?.store).toBeDefined()
expect(details?.store).toBe(conversation?.store)
expect(chatView?.store).toBe(conversation?.store)
// The empty slot is storeless (local state + useSessions derivation).
expect(empty?.store).toBeUndefined()
})
it('bash samples resolve differentially: scoped row for sub-sessions, global for roots', async () => {
it('mounts the bash sample as a keyed entry through the load-order seam', async () => {
const b = await bench()
await b.fiber.await()
const toolviews = b.ctx.get('toolviews') as ToolViewRegistry
const forChild = toolviews.resolve('bash', CHILD)
const forRoot = toolviews.resolve('bash', ROOT)
expect(forChild).toBeDefined()
expect(forRoot).toBeDefined()
expect(forChild!.component).not.toBe(forRoot!.component)
// The sample plugin's inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map((e) => e.options.key)).toEqual(['bash'])
})
it('plugin fiber disposal collects every registration (unload cascade)', async () => {
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {
const b = await bench()
await b.fiber.await()
await b.fiber.dispose()
expect(b.slots.entries('conversation')).toHaveLength(0)
// The declared ring collapses with its declaring entry, and the chat
// entry's keyed hole (with the sample's registration) collapses with it.
expect(b.slots.entries('conversation.view')).toHaveLength(0)
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
expect(b.slots.entries('details')).toHaveLength(0)
expect(b.slots.entries('conversation.empty')).toHaveLength(0)
expect(b.ctx.get('conversation')).toBeUndefined()
expect(b.ctx.get('toolviews')).toBeUndefined()
})
})
@@ -1,42 +1,22 @@
// @vitest-environment jsdom
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
// ToolViewOutlet inject cache + crash fallback + retry, StatsLine no-cache
// join, PendingCard reason strip, AssistantMarkdown single-line reasoning,
// ChatView view-body fallbacks, and apply's action lambdas.
// StatsLine no-cache join, PendingCard reason strip, AssistantMarkdown
// single-line reasoning. (Tool-row dispatch tails live with the keyed-slot
// machinery specs since the tool ring dissolved into renderSlot.)
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { act } from '@testing-library/react'
import type { SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import { hookOf } from './hook.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolViewProps, Translate } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
import { ToolViewOutlet } from '../src/client/chat/ToolViewOutlet.tsx'
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
const result = (callId: string): ToolResultNode => ({
kind: 'tool-result', seq: 3, callId,
call: { name: 'bash', argsRaw: '{"command":"x"}' },
content: [], isError: false, callView: null, resultView: null,
})
const viewProps = (): ToolViewProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails: vi.fn() },
t: ((k: string) => k) as Translate,
})
describe('MessageItem arms', () => {
it('steering bubbles carry the interjection badge and non-text rest blocks', () => {
const view = render(
@@ -86,71 +66,8 @@ describe('small branch tails', () => {
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine sessionId={SID} useSession={hookOf(source) as unknown as UseSession} />,
<StatsLine useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']} />,
)
expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy()
})
})
describe('ToolViewOutlet dispatch', () => {
it('caches the inject factory per (registration x session) and merges its props', () => {
const registry = new ToolViewRegistry()
const inject = vi.fn((sessionId: SessionId) => ({ extra: `injected:${sessionId}` }))
registry.register('bash',
(p: ToolViewProps & { extra: string }) => <div data-testid="row">{p.extra}</div>,
{ inject })
// Pure props machinery: the outlet feeds its own sessionId to the
// factory — no provider/context needed (terminal channel form).
const view = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
expect(view.getByTestId('row').textContent).toBe(`injected:${SID}`)
expect(inject).toHaveBeenCalledTimes(1)
// Remount under the SAME session: cache hit, factory not re-run.
view.unmount()
const second = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
expect(second.getByTestId('row').textContent).toBe(`injected:${SID}`)
expect(inject).toHaveBeenCalledTimes(1)
// A different session is a distinct cache key: factory runs once more.
second.unmount()
const other = render(
<ToolViewOutlet registry={registry} sessionId={'s2' as SessionId} toolName="bash" viewProps={viewProps()} />,
)
expect(other.getByTestId('row').textContent).toBe('injected:s2')
expect(inject).toHaveBeenCalledTimes(2)
})
it('a crashing custom row falls back to GenericToolCard and retries on re-registration', () => {
const registry = new ToolViewRegistry()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
// React dev builds re-dispatch boundary-caught errors as window 'error'
// events (invokeGuardedCallback); swallow them so vitest sees the caught path.
const swallow = (e: Event): void => { e.preventDefault() }
window.addEventListener('error', swallow)
try {
const Bomb = () => { throw new Error('row bomb') }
registry.register('bash', Bomb as never)
const view = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
// Crash caught: generic row rendered instead.
expect(view.getByText('Bash')).toBeTruthy()
// A new registration bumps the version; the boundary retries the custom row.
act(() => { registry.register('bash', (() => <div data-testid="fixed" />) as never) })
expect(view.getByTestId('fixed')).toBeTruthy()
} finally {
window.removeEventListener('error', swallow)
consoleError.mockRestore()
}
})
it('registry miss renders the generic row directly', () => {
const registry = new ToolViewRegistry()
const view = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
expect(view.getByText('Bash')).toBeTruthy()
})
})
@@ -1,21 +1,19 @@
// @vitest-environment jsdom
// StatsLine (chrome.footer first consumer): totals derivation + the RFC hard
// acceptance — zero renders during streaming. Bash sample: differential
// registry hits per session, teardown reverts to the generic row.
// StatsLine (rendered inside the chat view body): totals derivation + the RFC
// hard acceptance — zero renders during streaming. Bash sample row: the
// canonical sub-agent differential decided INSIDE the component off the
// standard useSessions kit (no registry predicates — tool ring dissolved).
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode,
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { hookOf } from './hook.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { ChromeProps, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { StatsLine, deriveStats } from '../src/client/chat/StatsLine.tsx'
import { BashRow, ScopedBashRow, registerBashSamples } from '../src/client/toolviews/bash-sample.tsx'
import { ToolViewOutlet } from '../src/client/chat/ToolViewOutlet.tsx'
import { childSessionScope } from '../src/client/chat/register.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { StatsLine, deriveStats, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
afterEach(cleanup)
@@ -77,8 +75,8 @@ describe('deriveStats', () => {
})
describe('StatsLine', () => {
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): ChromeProps {
return { sessionId: SID, useSession: hookOf(source) as unknown as UseSession }
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): StatsLineProps {
return { useSession: bindSnapshotSelector(source) }
}
it('renders the joined stats row and hides with zero steps', () => {
@@ -95,7 +93,7 @@ describe('StatsLine', () => {
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
const { set, source } = makeSource({ nodes: [assistant(1, 1)] })
let renders = 0
function Counting(p: ChromeProps) {
function Counting(p: StatsLineProps) {
renders += 1
return <StatsLine {...p} />
}
@@ -109,71 +107,79 @@ describe('StatsLine', () => {
})
})
describe('bash toolview samples', () => {
describe('bash sample row', () => {
const ROOT = 'root-1' as SessionId
const CHILD = 'child-1' as SessionId
const result = (callId: string): ToolResultNode => ({
kind: 'tool-result', seq: 3, callId,
call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
content: [], isError: false, callView: null, resultView: null,
})
const viewProps = (openDetails = vi.fn()): ToolViewProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails },
t: (k) => k,
})
function outlet(registry: ToolViewRegistry, sessionId: SessionId, p = viewProps()) {
return render(
<ToolViewOutlet registry={registry} sessionId={sessionId} toolName="bash" viewProps={p} />,
)
/** Real list-store engine: the family fixture the in-component parentId branch reads. */
function listStore() {
return createSnapshotStore<SessionListState>({
ids: [ROOT, CHILD],
byId: {
[ROOT]: { id: ROOT, title: 'r', running: false, updatedAt: 0 },
[CHILD]: { id: CHILD, title: 'c', parentId: ROOT, running: false, updatedAt: 0 },
},
current: undefined,
} as SessionListState)
}
it('differential rendering: scoped row for the matching session, global elsewhere', () => {
const registry = new ToolViewRegistry()
registerBashSamples(registry, (id) => id === ('swarm' as SessionId))
const scoped = outlet(registry, 'swarm' as SessionId)
const rowProps = (sessionId: SessionId, over?: {
store?: ReturnType<typeof listStore>
openDetails?: () => void
}): ToolRowProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
openDetails: over?.openDetails ?? vi.fn(),
sessionId,
useSessions: bindSnapshotSelector(over?.store ?? listStore()),
} as unknown as ToolRowProps)
it('differential rendering: the scoped variant in sub-sessions, global at roots', () => {
const scoped = render(<BashRow {...rowProps(CHILD)} />)
expect(scoped.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
const plain = outlet(registry, SID)
expect(scoped.getByText('scoped')).toBeTruthy()
const plain = render(<BashRow {...rowProps(ROOT)} />)
expect(plain.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
})
it('teardown removes both registrations and falls back to the generic row', () => {
const registry = new ToolViewRegistry()
const off = registerBashSamples(registry, () => true)
const view = outlet(registry, SID)
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
act(() => off())
expect(view.container.querySelector('[data-sample]')).toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
it('a session outside the list renders the global arm (no parent known)', () => {
const view = render(<BashRow {...rowProps('gone' as SessionId)} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
})
it('childSessionScope matches sub-sessions via the injected list read face', () => {
const child = 'child' as SessionId
const root = 'root' as SessionId
const scope = childSessionScope({
getSnapshot: () => ({
ids: [root, child],
current: undefined,
byId: {
[root]: { id: root, title: 'r', running: false, updatedAt: 0 },
[child]: { id: child, title: 'c', parentId: root, running: false, updatedAt: 0 },
},
}),
it('a live parentId write flips the row to the scoped variant (store subscription)', () => {
const store = listStore()
const orphan = 'late-child' as SessionId
store.update((d) => {
d.ids.push(orphan)
d.byId[orphan] = { id: orphan, title: 'l', running: false, updatedAt: 0 }
})
expect(scope(child)).toBe(true)
expect(scope(root)).toBe(false)
expect(scope('gone' as SessionId)).toBe(false)
const view = render(<BashRow {...rowProps(orphan, { store })} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
act(() => {
store.update((d) => { d.byId[orphan]!.parentId = ROOT })
})
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
})
it('sample rows summarize the command and hand clicks to openDetails', () => {
const open = vi.fn()
const p = viewProps(open)
const global = render(<BashRow {...p} />)
expect(global.getByText('Build')).toBeTruthy()
fireEvent.click(global.getByText('Build'))
expect(open).toHaveBeenCalledTimes(1)
const scoped = render(<ScopedBashRow {...p} />)
expect(scoped.getByText('scoped')).toBeTruthy()
it('summarizes the command and hands clicks to openDetails on both arms', () => {
const openGlobal = vi.fn()
const global = render(<BashRow {...rowProps(ROOT, { openDetails: openGlobal })} />)
// Two renders share document.body: query inside each container.
const globalRow = global.container.querySelector('[data-sample="bash-global"]')!
expect(globalRow.textContent).toContain('Build')
fireEvent.click(globalRow)
expect(openGlobal).toHaveBeenCalledTimes(1)
const openScoped = vi.fn()
const scoped = render(<BashRow {...rowProps(CHILD, { openDetails: openScoped })} />)
const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')!
expect(scopedRow.textContent).toContain('Build')
fireEvent.click(scopedRow)
expect(openScoped).toHaveBeenCalledTimes(1)
})
})
@@ -4,11 +4,11 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
afterEach(cleanup)
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import { classifyTool, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
@@ -28,6 +28,8 @@ describe('tool-call-model', () => {
expect(classifyTool('web_fetch')).toBe('read')
expect(classifyTool('web_search')).toBe('search')
expect(classifyTool('grep')).toBe('search')
expect(classifyTool('write')).toBe('write')
expect(classifyTool('edit')).toBe('edit')
expect(classifyTool('todo_write')).toBe('others')
})
@@ -48,6 +50,8 @@ describe('tool-call-model', () => {
it('keeps summaries single-line and falls back for opaque args', () => {
expect(toolRowModel('bash', running({ argsRaw: '{"command":"a\\nb"}' })).summary).toBe('a')
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/tmp/x.ts"}' })).summary).toBe('/tmp/x.ts')
expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
// Others rows prefix the real tool name into the summary slot (figma-flows
// ruling: static "Tool call" title, name rides the mutable summary).
expect(toolRowModel('x', running({ argsRaw: '{"n":1}' })).summary).toBe('x · {"n":1}')
@@ -114,12 +118,28 @@ describe('ToolRow', () => {
})
})
describe('ThinkRow', () => {
it('expands from either Think or the reasoning summary', () => {
const view = render(
<AssistantMarkdown
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
/>,
)
const row = view.getByRole('button')
fireEvent.click(view.getByText('Inspect the session'))
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText(/Check persistence/)).toBeTruthy()
fireEvent.click(view.getByText('Think'))
expect(row.getAttribute('aria-expanded')).toBe('false')
})
})
describe('GenericToolCard', () => {
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolViewProps => ({
callId: 'c1', toolName, block,
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails: vi.fn() },
t: (k) => k,
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
callId: 'c1', toolName, block, openDetails: vi.fn(),
})
it('renders the classified variant row from the frozen slice', () => {
@@ -138,10 +158,36 @@ describe('GenericToolCard', () => {
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
})
it('row click reaches actions.openDetails', () => {
it('renders edit with its dedicated title, icon variant, and path summary', () => {
const view = render(
<GenericToolCard {...props('edit', running({
name: 'edit',
argsRaw: '{"file_path":"src/x.ts","old_string":"before","new_string":"after"}',
}))} />,
)
expect(view.getByText('Edit')).toBeTruthy()
expect(view.getByText('src/x.ts')).toBeTruthy()
expect(view.container.querySelector('[data-variant="edit"]')).not.toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
})
it('renders write with its dedicated title, icon variant, and path summary', () => {
const view = render(
<GenericToolCard {...props('write', running({
name: 'write',
argsRaw: '{"file_path":"src/x.ts","content":"hello"}',
}))} />,
)
expect(view.getByText('Write')).toBeTruthy()
expect(view.getByText('src/x.ts')).toBeTruthy()
expect(view.container.querySelector('[data-variant="write"]')).not.toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
})
it('row click reaches openDetails', () => {
const p = props('bash', result())
const view = render(<GenericToolCard {...p} />)
fireEvent.click(view.getByText('List files'))
expect(p.actions.openDetails).toHaveBeenCalledTimes(1)
expect(p.openDetails).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,232 @@
// @vitest-environment jsdom
// The dissolved tool ring's acceptance chain on the REAL machinery stack:
// cordis Context + SlotsService ledger + the web-react renderer + this
// package's own apply — no outlet twins. Proves the keyed
// 'conversation.chat.toolview' hole end to end: registered rows dispatch by
// entryKey (the bash sample lands through its plugin), unregistered tools
// fall back to GenericToolCard at the render site, live registration/unload
// flips rows in place, duplicate keys fail loud, the inject channel feeds
// (sessionId) => I into row components, and a registrant's
// inject: ['slots', 'conversation'] load-order seam suspends on real fiber
// semantics until the service (and with it the hole declaration) is present.
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
const SID = 's1' as SessionId
afterEach(cleanup)
// The chat store persists under its declared key; clear between cases.
beforeEach(() => {
localStorage.clear()
})
const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({
kind: 'tool-result', seq, callId,
call: { name, argsRaw: args },
content: [], isError: false, callView: null, resultView: null,
})
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
}
/** Test-owned AppFrame role: declares the layout-owned children and renders the conversation area under the framework session provider. */
type AppRootProps = PropsRenderSlots<'conversation' | 'details' | 'conversation.empty'>
function AppRoot({ renderSlot, SessionProvider }: AppRootProps) {
return <SessionProvider>{() => renderSlot('conversation', {})}</SessionProvider>
}
/**
* Real-stack bench: SlotsService plugin, renderer installed, sessions/layout
* fakes at the service seams only (external boundaries), the package apply on
* its own fiber, and the test AppFrame occupying 'root'.
*/
async function bench(nodes: ToolResultNode[]) {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const slots = ctx.get('slots') as SlotsService
const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
const list = createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', running: false, updatedAt: 1 } },
current: SID,
} as SessionListState)
// Identity-stable cell: the renderer caches hooks per source and inject
// results per cell, both by object identity.
const cell = { sessionId: SID, session }
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
ctx.provide('sessions', {
list,
manager: { get: () => ({ loadOlder: vi.fn() }) },
scope: () => ({ get: () => scoped }),
cell: (id: string) => (id === SID ? cell : undefined),
create: vi.fn(),
open: vi.fn(),
})
ctx.provide('layout', layout)
ctx.provide('i18n', { bind: () => (key: string) => key })
slots.install(createSlotRenderer())
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, AppRoot)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, session, list, layout }
}
/** Render the whole tree through the ctx-level root seam (the shell's own entry). */
function mountApp(slots: SlotsService) {
return render(<>{slots.renderSlot('root', {})}</>)
}
describe('keyed toolview hole through the real machinery', () => {
it('dispatches registered rows by entryKey and unregistered tools to the GenericToolCard fallback', async () => {
const b = await bench([
toolResult(3, 'c1', 'bash'),
toolResult(4, 'c2', 'mystery', '{"n":1}'),
])
const view = mountApp(b.slots)
// bash: the sample plugin's keyed registration took the row (root
// session → global arm, decided inside the component off useSessions).
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.getByText('Build')).toBeTruthy()
// mystery: no registration under that key → render-site fallback.
expect(view.getByText('Tool call')).toBeTruthy()
})
it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => {
const b = await bench([toolResult(3, 'c1', 'bash')])
const view = mountApp(b.slots)
view.getByText('Build').click()
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
})
it('a live keyed registration takes over its tool row and unload reverts to the fallback', async () => {
const b = await bench([toolResult(3, 'c2', 'mystery', '{"n":1}')])
const view = mountApp(b.slots)
expect(view.getByText('Tool call')).toBeTruthy()
let dispose = (): void => {}
await act(async () => {
dispose = b.slots.register(
{ name: 'conversation.chat.toolview', key: 'mystery' },
() => <div data-testid="mystery-row" />)
})
// Per-key version tick: the row flipped without a remount of the view.
expect(view.getByTestId('mystery-row')).toBeTruthy()
expect(view.queryByText('Tool call')).toBeNull()
await act(async () => { dispose() })
expect(view.queryByTestId('mystery-row')).toBeNull()
expect(view.getByText('Tool call')).toBeTruthy()
})
it('a duplicate key registration fails loud at load', async () => {
const b = await bench([])
// The bash sample already holds the 'bash' key (later-wins retired with
// the ring — the keyed ledger throws instead).
expect(() => b.slots.register(
{ name: 'conversation.chat.toolview', key: 'bash' },
() => null,
)).toThrow(/key "bash"/)
})
it('the inject channel feeds (sessionId) => I into the row component', async () => {
const b = await bench([toolResult(3, 'c3', 'probe', '{"x":1}')])
const poked: string[] = []
b.slots.register({
name: 'conversation.chat.toolview',
key: 'probe',
// Two-way business face: data derived from the session id out, a
// callback closing over it back in — the askuser-pattern inject shape.
inject: (sessionId: SessionId) => ({
mark: `for:${sessionId}`,
poke: () => { poked.push(sessionId) },
}),
}, ({ mark, poke }: ToolRowProps & { mark: string; poke: () => void }) => (
<button data-testid="probe-row" onClick={poke}>{mark}</button>
))
const view = mountApp(b.slots)
const row = view.getByTestId('probe-row')
expect(row.textContent).toBe(`for:${SID}`)
row.click()
expect(poked).toEqual([SID])
})
})
describe('registrant load-order seam', () => {
it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const slots = ctx.get('slots') as SlotsService
ctx.provide('sessions', {
list: createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined } as SessionListState),
manager: { get: vi.fn() },
scope: () => undefined,
cell: () => undefined,
create: vi.fn(),
open: vi.fn(),
})
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('i18n', { bind: () => (key: string) => key })
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, AppRoot)
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
// semantics hold it — apply must not run while 'conversation' is absent.
// (Plain arrow, not vi.fn: mock functions carry a prototype and trip the
// fiber's isConstructor branch.)
let applyRuns = 0
const registrantApply = (registrantCtx: Context): void => {
applyRuns += 1
registrantCtx.slots.register(
{ name: 'conversation.chat.toolview', key: 'late' }, () => null)
}
const late = ctx.plugin({
name: 'late-registrant',
inject: ['slots', 'conversation'],
apply: registrantApply,
})
await Promise.resolve()
expect(applyRuns).toBe(0)
// Mounting the package resolves the seam: service present ⟹ the chat
// entry (and its hole declaration) is already on the ledger, so the
// suspended registrant lands without an undeclared-slot throw.
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await late.await()
expect(applyRuns).toBe(1)
expect(slots.entries('conversation.chat.toolview').map(e => e.options.key))
.toEqual(expect.arrayContaining(['bash', 'late']))
})
})
@@ -7,16 +7,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode, UserMessageNode,
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import { hookOf } from './hook.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConvViewProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '../src/client/stores.ts'
import { createChatView } from '../src/client/chat/ChatView.tsx'
import { ChatView } from '../src/client/chat/ChatView.tsx'
import { deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
afterEach(cleanup)
@@ -70,23 +68,41 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, callView: null,
})
/** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)
return bindSnapshotSelector(store)
}
function makeHarness(init?: Partial<ConversationSnapshot>) {
const { set, source } = makeSource(init)
const registry = new ToolViewRegistry()
const ChatView = createChatView({ toolviews: registry, t: (k) => k })
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const loadOlder = vi.fn()
// Selection rides the REAL chat store (same construction path as
// production; the view reads it through the ConvViewProps useStore share).
// production; the view reads it through the PropsStore useStore share).
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
// every tool lands on GenericToolCard); keyed dispatch to registered rows
// is the slot machinery's behavior, covered by its own specs.
const chat = createChatStore().create()
const props: ConvViewProps = {
const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot']
// SessionProvider seat arrives with the session-scope child declaration;
// ChatView never invokes it (render-prop pass-through stub).
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
const props: ChatViewSlotProps = {
sessionId: SID,
useSession: hookOf(source) as unknown as UseSession,
useStore: hookOf(chat),
actions: { openDetails, loadOlder },
useSession: bindSnapshotSelector(source),
useSessions: emptySessions(),
useStore: bindSnapshotSelector(chat),
actions: chat.actions,
renderSlot,
SessionProvider: SessionProviderStub,
openDetails,
loadOlder,
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
return { set, registry, ChatView, props, openDetails, loadOlder, setSelection }
return { set, ChatView, props, openDetails, loadOlder, setSelection }
}
describe('chat-flow derivation', () => {
@@ -171,11 +187,13 @@ describe('ChatView', () => {
const h = makeHarness({
nodes: [user(1, 'q'), assistant(2, 'old'), toolResult(3, 'a')],
})
// Count renderSlot invocations: the memo boundary holds when CallRow does
// not re-render, so the row's renderSlot call count freezes during chunks.
let rowRenders = 0
h.registry.register('bash', () => {
h.props.renderSlot = (((_key: string, _owner: object) => {
rowRenders += 1
return <div data-testid="counting-row" />
})
}) as unknown as ChatViewSlotProps['renderSlot'])
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('counting-row')).toBeTruthy()
const afterMount = rowRenders
@@ -213,21 +231,19 @@ describe('ChatView', () => {
expect(view.getByText('cmd-r1')).toBeTruthy()
})
it('a scoped toolview registration takes over rendering for its session only', () => {
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
h.registry.register('bash', () => <div data-testid="custom-bash" />, { scope: (id) => id === SID })
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('custom-bash')).toBeTruthy()
})
it('unregistering a toolview falls back to the generic row live', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const off = h.registry.register('bash', () => <div data-testid="custom-bash" />)
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('custom-bash')).toBeTruthy()
act(() => off())
expect(view.queryByTestId('custom-bash')).toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
const calls: { key: string; entryKey?: string }[] = []
h.props.renderSlot = (((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
return opts?.fallback ?? null
}) as unknown as ChatViewSlotProps['renderSlot'])
render(<h.ChatView {...h.props} />)
// Keyed dispatch: slot name is the declared hole, entryKey the wire tool
// name, and the fallback (GenericToolCard) renders on an empty ledger.
// (Registered-row takeover and live unload are slot machinery behavior,
// owned by the slot system's own specs.)
expect(calls).toEqual([{ key: 'conversation.chat.toolview', entryKey: 'bash' }])
})
it('prepend compensates scrollTop by the height delta; a trailing user node force-scrolls', () => {
@@ -287,17 +303,12 @@ describe('ChatView', () => {
expect(lv.getByText('载入历史…')).toBeTruthy()
})
it('renders approval cards while questions stay in the composer', () => {
it('pending interactions render placeholder cards', () => {
const h = makeHarness({
pending: [
new PendingWait('approval', RpcId('r1'), SID,
{ approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn()),
new PendingWait('question', RpcId('r2'), SID,
{ questions: [{ id: 'mode', question: 'Composer only?', options: [{ label: 'Yes' }] }] } as PendingWait<'question'>['payload'], vi.fn()),
],
pending: [new PendingWait('approval', RpcId('r1'), SID,
{ approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())],
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText(/等待审批/)).toBeTruthy()
expect(view.queryByText('Composer only?')).toBeNull()
})
})
@@ -1,21 +1,22 @@
// @vitest-environment jsdom
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
// Bash sample error pill and registry disposer
// idempotence re-entry, register.ts explicit bashSampleScope override, the
// node-half empty apply, and AssistantMarkdown reasoning/unknown block arms.
// PendingCard question arm, bash sample error pill, the node-half empty
// apply, and AssistantMarkdown reasoning/unknown block arms.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationService, Translate, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply as nodeApply } from '../src/index.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
import { registerChat } from '../src/client/chat/register.ts'
afterEach(cleanup)
@@ -32,6 +33,13 @@ describe('tails', () => {
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
})
it('PendingCard renders the question arm with its count', () => {
const view = render(
<PendingCard item={new PendingWait('question', RpcId('r1'), 's1' as SessionId, { questions: [{}, {}] } as PendingWait<'question'>['payload'], vi.fn())} />,
)
expect(view.getByText(/等待回答(2 题)/)).toBeTruthy()
})
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
const view = render(
<AssistantMarkdown
@@ -58,11 +66,8 @@ describe('tails', () => {
call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
content: [], isError: false, callView: null, resultView: null,
}
const props: ToolViewProps = {
callId: 'c5', toolName: 'todo_write', block: settled,
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails: vi.fn() },
t: ((k: string) => k) as Translate,
const props: ToolRowOwnerProps = {
callId: 'c5', toolName: 'todo_write', block: settled, openDetails: vi.fn(),
}
const view = render(<GenericToolCard {...props} />)
// Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
@@ -70,49 +75,25 @@ describe('tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('BashRow shows the failed pill on error results', () => {
it('BashRow shows the failed pill on error results (root session arm)', () => {
const errorResult: ToolResultNode = {
kind: 'tool-result', seq: 1, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
content: [], isError: true, callView: null, resultView: null,
}
const props: ToolViewProps = {
callId: 'c1', toolName: 'bash', block: errorResult,
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails: vi.fn() },
t: ((k: string) => k) as Translate,
}
// Root session (no parentId): the global arm renders, error pill visible.
const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({
ids: [sid],
byId: { [sid]: { id: sid, title: 'r', running: false, updatedAt: 0 } },
current: undefined,
} as SessionListState)
const props = {
callId: 'c1', toolName: 'bash', block: errorResult, openDetails: vi.fn(),
sessionId: sid, useSessions: bindSnapshotSelector(list),
} as unknown as ToolRowProps
const view = render(<BashRow {...props} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.getByText('failed')).toBeTruthy()
})
it('registry disposer re-entry is a no-op after the entry was already removed', () => {
const registry = new ToolViewRegistry()
const off = registry.register('bash', (() => null) as never)
const v1 = registry.getVersion()
off()
const v2 = registry.getVersion()
off()
expect(registry.getVersion()).toBe(v2)
expect(v2).toBeGreaterThan(v1)
})
it('registerChat registers the chat view with the stats footer and disposes cleanly', () => {
const disposer = vi.fn()
const calls: unknown[] = []
const conversation = {
registerView: (entry: unknown) => {
calls.push(entry)
return disposer
},
} as unknown as ConversationService
const toolviews = new ToolViewRegistry()
const off = registerChat({ conversation, toolviews, t: ((k: string) => k) as Translate })
const entry = calls[0] as { id: string; chrome?: { footer?: unknown } }
expect(entry.id).toBe('chat')
// footer is a memo exotic component (object, not plain function).
expect(entry.chrome?.footer).toBeDefined()
off()
expect(disposer).toHaveBeenCalledTimes(1)
})
})
@@ -1,18 +1,16 @@
// @vitest-environment jsdom
// Final branch tails for the coverage gate, terminal slot form: apply's
// need() throw, AssistantMarkdown non-final reasoning, StatsLine usage-less
// node, DetailsPanel titleless selection, registry disposer after a foreign
// removal emptied the list. (The old cwd WeakMap-cache account retired with
// the mechanism — derivation lives in EmptyState now, covered by the
// skeleton specs.)
// Final branch tails for the coverage gate, terminal slot form:
// AssistantMarkdown non-final reasoning, StatsLine usage-less node,
// DetailsPanel titleless selection. (The old cwd WeakMap-cache account
// retired with the mechanism — derivation lives in EmptyState now, covered
// by the skeleton specs.)
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { hookOf } from './hook.ts'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '../src/client/stores.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
@@ -54,7 +52,7 @@ describe('render branch tails', () => {
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine sessionId={SID} useSession={hookOf(source) as unknown as UseSession<ConversationSnapshot>} />,
<StatsLine useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
)
expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
})
@@ -76,9 +74,9 @@ describe('render branch tails', () => {
const view = render(
<DetailsPanel
sessionId={SID}
useSession={hookOf({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
useSessions={hookOf(emptyList)}
useStore={hookOf(chat)}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
useSessions={bindSnapshotSelector(emptyList)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
@@ -86,15 +84,4 @@ describe('render branch tails', () => {
expect(view.getByText('详情')).toBeTruthy()
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
})
it('registry disposer tolerates the list already emptied by a sibling disposer', () => {
const registry = new ToolViewRegistry()
const offA = registry.register('bash', () => null)
const offB = registry.register('bash', () => null)
offA()
offB()
// Both entries gone; a re-register works from a fresh list.
registry.register('bash', () => null)
expect(registry.resolve('bash', SID)).toBeDefined()
})
})
@@ -2,9 +2,10 @@
/**
* ConversationService orchestration half after the store-seat slimming:
* scope-addressed send/cancel (result folding, root throw), the startSession
* chain (create → sessions.open → scoped send), views ordering, and the
* service-unavailable loud failures. Selection/draft state left this service
* for the declared chat store (chat-store.spec.ts / selection-survival.spec.ts).
* chain (create → sessions.open → scoped send), and the service-unavailable
* loud failures. Selection/draft state left this service for the declared
* chat store (chat-store.spec.ts / selection-survival.spec.ts); the view
* registry left for the 'conversation.view' slot (views-type-chain.spec.tsx).
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
@@ -68,7 +69,8 @@ async function bench(opts?: { sessions?: boolean }) {
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
} as unknown as SessionsService
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
const fiber = ctx.plugin((pluginCtx) => { void new ConversationService(pluginCtx) })
// Class-plugin mount — the same form apply.ts uses in production.
const fiber = ctx.plugin(ConversationService)
await fiber.await()
const svc = ctx.get('conversation') as ConversationService
const scopedSvc = (id: SessionId) => mint(id).get('conversation') as ConversationService
@@ -149,17 +151,3 @@ describe('service-unavailable loud failures', () => {
.rejects.toThrow(/conversation service unavailable through the new scope/)
})
})
describe('views ordering', () => {
it('orders by explicit order with undefined treated as zero (both comparator arms)', async () => {
const b = await bench()
const entry = (id: string, order?: number) => ({
id, label: id, component: () => null,
...(order !== undefined ? { order } : {}),
})
b.svc.registerView(entry('z-late', 5) as never)
b.svc.registerView(entry('default-zero') as never)
b.svc.registerView(entry('first', -1) as never)
expect(b.svc.views().map(v => v.id)).toEqual(['first', 'default-zero', 'z-late'])
})
})
@@ -11,10 +11,10 @@ import { hookOf } from './hook.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps, SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { createChatStore } from '../src/client/stores.ts'
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
import { ConversationRoot, type ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
@@ -22,13 +22,8 @@ afterEach(cleanup)
const SID = 's1' as SessionId
/** Fallback-only chain stub (no takeover registered in these benches). */
const fallbackRenderSlotChain: ConversationSlotProps['renderSlotChain'] =
const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] =
(_key, _owner, opts) => opts?.fallback ?? null
/** Non-chain renderSlot stub: ConversationRoot renders no non-chain child keys. */
const unusedRenderSlot: ConversationSlotProps['renderSlot'] =
(() => { throw new Error('no non-chain child keys') }) as unknown as ConversationSlotProps['renderSlot']
/** Standard-seat stub: ConversationRoot never renders it, delivery is mandatory in the props type. */
const StubSessionProvider: ConversationSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
function snapshotBase(): ConversationSnapshot {
return {
@@ -61,9 +56,11 @@ function listHook(rows: { id: string; title: string; cwd?: string; parentId?: st
}
describe('ConversationRoot branches', () => {
const chatEntry: ViewEntry = {
id: 'chat', label: 'Chat', component: () => <div data-testid="view-body" />,
} as unknown as ViewEntry
const chatTab: ViewTab = { id: 'chat', label: 'Chat' }
/** renderSlot stub in the outlet's baked shape (ring key + only filter marker). */
const stubRenderSlot = (() => <div data-testid="view-body" />) as unknown as ConversationRootProps['renderSlot']
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
function rootProps(over?: {
rows?: { id: string; title: string; parentId?: string }[]
@@ -78,15 +75,13 @@ describe('ConversationRoot branches', () => {
useSessions={listHook(over?.rows ?? [])}
useStore={hookOf(chat)}
actions={chat.actions}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
renderSlot={stubRenderSlot}
renderSlotChain={fallbackRenderSlotChain}
SessionProvider={SessionProviderStub}
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
loadOlder={vi.fn()}
open={open}
renderSlot={unusedRenderSlot}
renderSlotChain={fallbackRenderSlotChain}
SessionProvider={StubSessionProvider}
/>,
)
return { view, open, chat }
@@ -131,7 +126,7 @@ describe('ConversationRoot branches', () => {
it('an unknown stored view id falls back to the first registered view', () => {
const { chat } = rootProps({})
cleanup()
chat.actions.setView('gone' as never)
chat.actions.setView('gone')
const view = render(
<ConversationRoot
sessionId={SID}
@@ -139,15 +134,13 @@ describe('ConversationRoot branches', () => {
useSessions={listHook([])}
useStore={hookOf(chat)}
actions={chat.actions}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
renderSlot={stubRenderSlot}
renderSlotChain={fallbackRenderSlotChain}
SessionProvider={SessionProviderStub}
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
loadOlder={vi.fn()}
open={vi.fn()}
renderSlot={unusedRenderSlot}
renderSlotChain={fallbackRenderSlotChain}
SessionProvider={StubSessionProvider}
/>,
)
expect(view.getByTestId('view-body')).toBeTruthy()
@@ -10,13 +10,14 @@
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import { hookOf } from './hook.ts'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, PendingInteraction, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps, SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
// Export discipline: packages/client/AGENTS.md.
import { createChatStore } from '../src/client/stores.ts'
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
@@ -25,9 +26,6 @@ import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
const sid = (s: string): SessionId => s as SessionId
/** Standard-seat stub: ConversationRoot never renders it, delivery is mandatory in the props type. */
const StubSessionProvider: ConversationSlotProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))}</>
afterEach(cleanup)
beforeEach(() => {
localStorage.clear()
@@ -47,7 +45,7 @@ function fakeSession(init: Partial<FakeSnapshot> = {}) {
const store = createSnapshotStore<FakeSnapshot>({
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init,
})
return { store, useSession: hookOf(store) as unknown as UseSession<ConversationSnapshot> }
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
/** Sessions-list stub: the standard useSessions hook over a snapshot store. */
@@ -61,9 +59,12 @@ function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?
}])),
current: undefined,
} as SessionListState)
return { store, useSessions: hookOf(store) }
return { store, useSessions: bindSnapshotSelector(store) }
}
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))}</>
describe('EmptyState', () => {
it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => {
const { useSessions } = fakeSessions([
@@ -102,8 +103,8 @@ describe('EmptyState', () => {
describe('ConversationRoot', () => {
function bench(
views: ViewEntry[], activeView?: string, init: Partial<FakeSnapshot> = {},
renderSlotChain?: ConversationSlotProps['renderSlotChain'],
tabs: ViewTab[], activeView?: string, init: Partial<FakeSnapshot> = {},
renderSlotChain?: ConversationRootProps['renderSlotChain'],
) {
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init })
const { useSessions } = fakeSessions([
@@ -111,45 +112,42 @@ describe('ConversationRoot', () => {
{ id: 's1', title: 'child', parentId: 'root' },
])
const chat = createChatStore().create()
if (activeView !== undefined) chat.actions.setView(activeView as never)
if (activeView !== undefined) chat.actions.setView(activeView)
const send = vi.fn()
const stop = vi.fn()
const openDetails = vi.fn()
const loadOlder = vi.fn()
const open = vi.fn()
// The renderSlot share as the outlet would bake it: renders a marker for
// the ring key carrying the active-id filter (a Mock cannot satisfy the
// generic method type directly — cast once at the prop seam).
const renderSlot = vi.fn((key: string, _owner: object, opts?: { only?: string }) => (
<div data-testid={`view-${opts?.only ?? '(all)'}`} data-slot={key} />
))
const ui = render(
<ConversationRoot
sessionId={sid('s1')}
useSession={useSession}
useSessions={useSessions}
useStore={hookOf(chat)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot as unknown as ConversationRootProps['renderSlot']}
renderSlotChain={renderSlotChain ?? ((_key, _owner, opts) => opts?.fallback ?? null)}
SessionProvider={SessionProviderStub}
views={{
list: () => views,
list: () => tabs,
subscribe: () => () => {},
version: () => 1,
}}
send={send}
stop={stop}
openDetails={openDetails}
loadOlder={loadOlder}
open={open}
renderSlot={(() => { throw new Error('no non-chain child keys') }) as unknown as ConversationSlotProps['renderSlot']}
renderSlotChain={renderSlotChain ?? ((_key, _owner, opts) => opts?.fallback ?? null)}
SessionProvider={StubSessionProvider}
/>)
return { ui, chat, send, stop, open }
return { ui, chat, send, stop, open, renderSlot }
}
/** View bodies record their mount via testid (renderView is in-component now). */
const view = (id: string, label: string): ViewEntry =>
({
id, label,
component: (() => <div data-testid={`view-${id}`} />) as unknown as FC<never>,
}) as unknown as ViewEntry
const tab = (id: string, label: string): ViewTab => ({ id, label })
it('renders breadcrumb chain (useSessions-derived), meta turns, and the default chat view', () => {
const { open } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
const { open } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
expect(screen.getByText('proj')).toBeTruthy()
expect(screen.getByText('child')).toBeTruthy()
expect(screen.getByText(/2 turns/)).toBeTruthy()
@@ -161,33 +159,25 @@ describe('ConversationRoot', () => {
})
it('switches views through the store view field and falls back on unknown ids', () => {
const { chat } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
const { chat } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(chat.store.getSnapshot().view).toBe('trajectory')
expect(screen.getByTestId('view-trajectory')).toBeTruthy()
cleanup()
// A stale persisted id (its view plugin unloaded) falls to the first view.
bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')], 'ghost-view')
bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')], 'ghost-view')
expect(screen.getByTestId('view-chat')).toBeTruthy()
})
it('mounts chrome header/footer around the view body', () => {
const entry = {
id: 'chat', label: 'Chat',
component: () => <div data-testid="body" />,
chrome: {
header: () => <div data-testid="hd" />,
footer: () => <div data-testid="ft" />,
},
} as unknown as ViewEntry
bench([entry])
expect(screen.getByTestId('hd')).toBeTruthy()
expect(screen.getByTestId('body')).toBeTruthy()
expect(screen.getByTestId('ft')).toBeTruthy()
it('renders the active view through the declared ring slot with the only filter', () => {
const { renderSlot } = bench([tab('chat', 'Chat')])
// No owner share: views take everything from the standard kit (contract).
expect(renderSlot).toHaveBeenCalledWith('conversation.view', {}, { only: 'chat' })
expect(screen.getByTestId('view-chat').getAttribute('data-slot')).toBe('conversation.view')
})
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
const { chat, send } = bench([view('chat', 'Chat')])
const { chat, send } = bench([tab('chat', 'Chat')])
expect(screen.queryByRole('tablist')).toBeNull()
const box = screen.getByPlaceholderText(/输入消息/)
fireEvent.change(box, { target: { value: 'hi' } })
@@ -197,12 +187,12 @@ describe('ConversationRoot', () => {
expect(send).toHaveBeenCalledWith('hi', 'queue')
})
it('dispatches the pending list to the composer chain instead of rendering InputBar', () => {
const renderSlotChain = vi.fn(() => <div>question takeover</div>) as unknown as ConversationSlotProps['renderSlotChain']
bench([view('chat', 'Chat')], undefined, {
pending: [new PendingWait('question', RpcId('rq'), sid('s1'),
{ questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn())],
}, renderSlotChain)
it('dispatches the pending list to the composer chain; all-decline falls back to InputBar', () => {
const wait = new PendingWait('question', RpcId('rq'), sid('s1'),
{ questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn())
// A matching entry takes the composer over.
const renderSlotChain = vi.fn(() => <div>question takeover</div>) as unknown as ConversationRootProps['renderSlotChain']
bench([tab('chat', 'Chat')], undefined, { pending: [wait] }, renderSlotChain)
expect(screen.getByText('question takeover')).toBeTruthy()
expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull()
// The owner dispatches the raw pending list (chain currency); routing
@@ -214,6 +204,11 @@ describe('ConversationRoot', () => {
}),
expect.objectContaining({ fallback: expect.anything() }),
)
cleanup()
// Zero registered entries (default all-decline stub): the fallback IS the
// default InputBar — behavior equals the pre-chain composer.
bench([tab('chat', 'Chat')], undefined, { pending: [wait] })
expect(screen.getByPlaceholderText(/输入消息/)).toBeTruthy()
})
})
@@ -229,7 +224,7 @@ describe('DetailsPanel', () => {
sessionId={sid('s1')}
useSession={useSession}
useSessions={useSessions}
useStore={hookOf(chat)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={closeDetails}
/>)
@@ -1,62 +0,0 @@
/**
* Tool-ring Entry typing (design §7): I inferred from the inject factory at
* the register site, component must accept ToolViewProps & I, and the resolve
* read face carries the erased-but-present inject. Compile-time checks via
* @ts-expect-error pairs; the runtime assertions just keep vitest happy.
*/
import { describe, expect, it } from 'vitest'
import type { FC } from 'react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
const sid = (s: string): SessionId => s as SessionId
// Positive control: component's own injected share matches the factory's product.
interface RowInjected { useMyStore: () => number }
const InjectedRowComp: FC<ToolViewProps & RowInjected> = () => null
// Plain rows take the shared props only.
const PlainRowComp: FC<ToolViewProps> = () => null
describe('tool-ring entry typing', () => {
it('register infers I from the inject factory and accepts a matching component', () => {
const reg = new ToolViewRegistry()
const off = reg.register('bash', InjectedRowComp, {
inject: () => ({ useMyStore: () => 1 }),
})
expect(reg.resolve('bash', sid('s'))?.inject).toBeDefined()
off()
})
it('injectless registration needs no options and resolves without inject', () => {
const reg = new ToolViewRegistry()
reg.register('read', PlainRowComp)
expect('inject' in (reg.resolve('read', sid('s')) ?? {})).toBe(false)
})
it('compile-time: factory product must cover the component injected share', () => {
const reg = new ToolViewRegistry()
reg.register('bash', InjectedRowComp, {
// @ts-expect-error the factory misses useMyStore, which the component requires
inject: () => ({ somethingElse: 1 }),
})
expect(true).toBe(true)
})
// Known boundary (not asserted): a component demanding an injected share CAN
// register bare — with I defaulting to `object`, FC<ToolViewProps & RowInjected>
// is structurally assignable to FC<ToolViewProps & object> (parameter
// bivariance over a wider props type). The register-site guarantee holds in
// the direction that matters: WITH an inject factory, its product must cover
// the component's share (previous case). The bare-register gap is the same
// one SlotMap's single-kind register has and is accepted by design §7.
it('compile-time: scope filter receives the branded SessionId', () => {
const reg = new ToolViewRegistry()
reg.register('bash', PlainRowComp, {
// @ts-expect-error number is not assignable to SessionId
scope: (id: number) => id > 0,
})
expect(true).toBe(true)
})
})
@@ -1,101 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
const sid = (s: string) => s as SessionId
const comp = (name: string) => {
const fc = () => null
fc.displayName = name
return fc as unknown as import('react').FC<ToolViewProps>
}
describe('ToolViewRegistry', () => {
it('resolves a global registration for any session', () => {
const reg = new ToolViewRegistry()
const bash = comp('Bash')
reg.register('bash', bash)
expect(reg.resolve('bash', sid('a'))?.component).toBe(bash)
expect(reg.resolve('bash', sid('b'))?.component).toBe(bash)
expect(reg.resolve('read', sid('a'))).toBeUndefined()
})
it('prefers a matching scope filter over the global registration', () => {
const reg = new ToolViewRegistry()
const global = comp('Global')
const swarm = comp('Swarm')
reg.register('bash', global)
reg.register('bash', swarm, { scope: id => id === sid('swarm-1') })
expect(reg.resolve('bash', sid('swarm-1'))?.component).toBe(swarm)
expect(reg.resolve('bash', sid('plain'))?.component).toBe(global)
})
it('later registration wins within the same tier, scoped and global', () => {
const reg = new ToolViewRegistry()
const s1 = comp('S1')
const s2 = comp('S2')
const g1 = comp('G1')
const g2 = comp('G2')
reg.register('bash', g1)
reg.register('bash', s1, { scope: () => true })
reg.register('bash', s2, { scope: () => true })
reg.register('bash', g2)
expect(reg.resolve('bash', sid('x'))?.component).toBe(s2)
const scopeless = new ToolViewRegistry()
scopeless.register('bash', g1)
scopeless.register('bash', g2)
expect(scopeless.resolve('bash', sid('x'))?.component).toBe(g2)
})
it('a non-matching scope filter falls through to global, then undefined', () => {
const reg = new ToolViewRegistry()
const scoped = comp('Scoped')
reg.register('bash', scoped, { scope: () => false })
expect(reg.resolve('bash', sid('x'))).toBeUndefined()
const global = comp('Global')
reg.register('bash', global)
expect(reg.resolve('bash', sid('x'))?.component).toBe(global)
})
it('disposer removes exactly its registration and is idempotent', () => {
const reg = new ToolViewRegistry()
const g = comp('G')
const s = comp('S')
const off = reg.register('bash', s, { scope: () => true })
reg.register('bash', g)
off()
off()
expect(reg.resolve('bash', sid('x'))?.component).toBe(g)
})
it('unregistering the last entry resolves undefined (GenericToolCard fallback)', () => {
const reg = new ToolViewRegistry()
const off = reg.register('bash', comp('B'))
off()
expect(reg.resolve('bash', sid('x'))).toBeUndefined()
})
it('carries the inject factory through resolve', () => {
const reg = new ToolViewRegistry()
const inject = () => ({})
reg.register('bash', comp('B'), { inject })
expect(reg.resolve('bash', sid('x'))?.inject).toBe(inject)
reg.register('read', comp('R'))
expect('inject' in reg.resolve('read', sid('x'))!).toBe(false)
})
it('notifies subscribers and bumps the version on register and dispose', () => {
const reg = new ToolViewRegistry()
const fn = vi.fn()
const unsub = reg.subscribe(fn)
const v0 = reg.getVersion()
const off = reg.register('bash', comp('B'))
expect(fn).toHaveBeenCalledTimes(1)
expect(reg.getVersion()).toBeGreaterThan(v0)
off()
expect(fn).toHaveBeenCalledTimes(2)
unsub()
reg.register('read', comp('R'))
expect(fn).toHaveBeenCalledTimes(2)
})
})
@@ -1,94 +0,0 @@
// Tool-ring type-chain samples (design §9 item 5, toolviews half): the
// register→inject→resolve chain where `I` is inferred from the inject
// factory and proved against the component at the register site, plus
// expect-error duals. Tool names stay an open set (no per-tool props table —
// design §7); the strong typing under test is Entry-internal. The known
// bare-register variance edge (FC<Props & I> assignable to FC<Props & object>
// without an inject factory) is accepted by design §7 and deliberately not
// pinned here. Follows the slots-ring exemplar's shape.
import { describe, expect, it } from 'vitest'
import type { FC, ReactNode } from 'react'
import type { ToolViewOptions, ToolViewProps } from '../src/client/contract/toolview.ts'
import { ToolViewRegistry } from '../src/client/toolviews/registry.ts'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
const sid = (s: string): SessionId => s as SessionId
/** Registrant's own injected share (locally declared — ownership rule). */
interface RowInjected { useRuns: () => number; actions2: { rerun: () => void } }
const InjectedRow: FC<ToolViewProps & RowInjected> = () => null
const PlainRow: FC<ToolViewProps> = () => null
describe('tool-ring type-chain negatives (compile-time; body never runs)', () => {
it('holds the negative samples as expect-error sites', () => {
const negatives = (registry: ToolViewRegistry) => {
// 1. Inject factory under-produces the component's declared share:
// I infers from the factory, and the component position then fails.
registry.register(
'bash',
// @ts-expect-error component wants actions2, which the factory never produces
InjectedRow,
{ inject: () => ({ useRuns: () => 1 }) },
)
// 2. Inject factory produces a drifted value type for a declared key
// (I infers from the component position here, so TS flags the factory).
registry.register(
'bash',
InjectedRow,
// @ts-expect-error useRuns returns string here, component wants number
{ inject: () => ({ useRuns: () => 'one', actions2: { rerun: () => {} } }) },
)
// 3. Options object drifts: scope filter with a wrong parameter shape.
const badScope: ToolViewOptions<RowInjected> = {
// @ts-expect-error scope takes a SessionId, not a numeric index
scope: (index: number) => index > 0,
}
void badScope
// 4. Component demanding props outside ToolViewProps & I (a key neither
// standard nor injected) cannot register even with a full factory.
const Overreaching: FC<ToolViewProps & RowInjected & { fromNowhere: boolean }> = () => null
registry.register(
'bash',
// @ts-expect-error fromNowhere is neither a standard prop nor produced by the factory
Overreaching,
{ inject: (): RowInjected => ({ useRuns: () => 1, actions2: { rerun: () => {} } }) },
)
return null as ReactNode
}
expect(negatives).toBeTypeOf('function')
})
})
describe('tool-ring full chain (positive dual)', () => {
it('registers with an inferred inject share, resolves by scope order, and reads the erased face back', () => {
const registry = new ToolViewRegistry()
// Registration: I inferred from the factory, component proved ⊇ ToolViewProps & I.
const disposeGlobal = registry.register('bash', InjectedRow, {
// Terminal channel form: the factory receives the session id only.
inject: (sessionId: SessionId): RowInjected => ({
useRuns: () => sessionId.length,
actions2: { rerun: () => {} },
}),
})
const disposeScoped = registry.register('bash', PlainRow, {
scope: id => id === sid('swarm-1'),
})
// Resolve: scope match beats global; elsewhere the global row wins.
expect(registry.resolve('bash', sid('swarm-1'))?.component).toBe(PlainRow)
const global = registry.resolve('bash', sid('other'))
expect(global?.component).toBe(InjectedRow)
// Read face: I is erased to object, the factory reference survives; the
// outlet-side restoration is the budgeted cast (same boundary as slots).
const injected = (global?.inject as (sessionId: SessionId) => RowInjected)(sid('ab'))
expect(injected.useRuns()).toBe(2)
// Unknown tool → undefined (caller falls back to the generic card).
expect(registry.resolve('ghost-tool', sid('other'))).toBeUndefined()
disposeScoped()
expect(registry.resolve('bash', sid('swarm-1'))?.component).toBe(InjectedRow)
disposeGlobal()
expect(registry.resolve('bash', sid('other'))).toBeUndefined()
})
})

Some files were not shown because too many files have changed in this diff Show More