diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 1868da83b3..d55103ce00 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-gui-web-client-architecture.md: 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 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index f21b840493..6e1cbc2d1e 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -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`/`ChromePropsOf` compose base + extension, so a view with no declaration gets the base for free while ui-trajectory's entries carry real per-view props) and the **tool ring** (tool names stay an open set — no global key table; typing hardens inside the entry: `ToolViewProps.block` is the real `ToolCallBlock` union defined in runtime, and register infers the registrant's injected share like slots do). +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: '', 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 `..`, 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 | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index a50dc556cf..9e2b3ef60d 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -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`/`ChromePropsOf` 组合基座+扩展,无声明的视图免费得基座,ui-trajectory 的两个 entry 带真 per-view props)与**工具环**(tool 名保持开放集——无全局键表;类型强化在 entry 内部:`ToolViewProps.block` 是 runtime 定义的真 `ToolCallBlock` union,register 同 slots 一样推断注册方注入份额)。 +slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 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: '', 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=2;import 只准指向 ≤ 自己的层级;兄弟域边即失败)。将来拆包=每个域目录升格为包+机械改写 import 路径。 +域实现文件永不 import 兄弟域——共享面一律走 `contract/`(如 toolviews 样例从契约取 `ToolRowProps`,永不碰 chat 内部)。`scripts/verify-client-domain-graph.ts` 把守分层(contract=0、域=1、apply/index=2;import 只准指向 ≤ 自己的层级;兄弟域边即失败)。将来拆包=每个域目录升格为包+机械改写 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 的按插件状态面已保留,渐进点亮日后可落地而无需重构 | diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml new file mode 100644 index 0000000000..6de82d1c9b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-23-toolview-dissolution.md: a420c5945d0272cf8087d5f623e9c383c286d7c2 +2026-07-23-toolview-dissolution.zh.md: 47c1f392f5f7ddbf4e6c686b2574faa7987e6126 diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md new file mode 100644 index 0000000000..a420c5945d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md @@ -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: '', 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 `..`, 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. diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md new file mode 100644 index 0000000000..47c1f392f5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md @@ -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 声明槽、从不声明 key;ask-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: '', 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']` 才排在槽声明之后,这条约定由序缝构造保证正确、但不对第三方静态强制。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml new file mode 100644 index 0000000000..b16ef70d7c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-20-config-hot-reload-resilience.md: 1a8e29c603ede50b60199e9151fca58dadcc3d40 +2026-07-20-config-hot-reload-resilience.zh.md: 6c7a421bfa84504a36d5329e13a485bf72cc6b6c diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md new file mode 100644 index 0000000000..1a8e29c603 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md @@ -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 ` 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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md new file mode 100644 index 0000000000..6c7a421bfa --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.zh.md @@ -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 `,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 上会失败。 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index a8c9c28d58..7addc991d2 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-20-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 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index e349374a6b..514bb5b12a 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index 88210dc386..16fada82c5 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -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` 和产品身份上冲突;在同一包形态内认领默认位,把最终的合并冲突限制在小小的分发链上。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.i18n.yaml index 321131ac96..f3fba8a006 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-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 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.md index de9a550221..e5600f0ab5 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.zh.md index 25d1d44845..3798b0518d 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-reload-command.zh.md @@ -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 保留运行中的树。 diff --git a/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.i18n.yaml new file mode 100644 index 0000000000..1aee1563ad --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-23-browser-demo-gif-recording.md: 096edf453d6b61c4d9046b284ef67a460edf4e88 +2026-07-23-browser-demo-gif-recording.zh.md: f5b8eac1c8dd57a59e9c2293ecc71511078a4896 diff --git a/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.md b/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.md new file mode 100644 index 0000000000..096edf453d --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.md @@ -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. diff --git a/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.zh.md b/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.zh.md new file mode 100644 index 0000000000..f5b8eac1c8 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.zh.md @@ -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 校验器和文档门禁覆盖。 diff --git a/.agents/skills/record-browser-gif/SKILL.md b/.agents/skills/record-browser-gif/SKILL.md new file mode 100644 index 0000000000..e48e16ca40 --- /dev/null +++ b/.agents/skills/record-browser-gif/SKILL.md @@ -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. diff --git a/.agents/skills/record-browser-gif/agents/openai.yaml b/.agents/skills/record-browser-gif/agents/openai.yaml new file mode 100644 index 0000000000..720f55f7dc --- /dev/null +++ b/.agents/skills/record-browser-gif/agents/openai.yaml @@ -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." diff --git a/.agents/skills/record-browser-gif/scripts/encode_gif.py b/.agents/skills/record-browser-gif/scripts/encode_gif.py new file mode 100755 index 0000000000..2a14ae47fd --- /dev/null +++ b/.agents/skills/record-browser-gif/scripts/encode_gif.py @@ -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() diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index d3011ed85f..5bde15dc2c 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -9,7 +9,7 @@ Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-..` (e.g. `'conversation.chat.toolview'`). 3. **Component props are the four shares, all derived**: `PropsRuntime` (SlotMap: owner params + `useSession`/`sessionId` on session scope + `useSessions`) & `PropsRenderSlots` (children keys) & `PropsStore` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally. 4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useStore`, `renderSlot` are the four seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.) 5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription. @@ -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`). 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`). 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 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 66eb5b22ac..2f6fc0259c 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -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. diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 77b51a68b5..b90812916a 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -51,7 +51,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 /** diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index ec63b8f70d..c7d5bf9273 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -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() - /** 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() /** @@ -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): 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.) diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 9d18069887..0dff0bb644 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -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([ + ['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() }) diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 3bf1c3bd6e..6d3cec90ea 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -1,12 +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. 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). + +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: `), 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. 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 · ` or `Edit · ` summary while retaining the shared row-to-details interaction. -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). +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: '', 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). -`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). +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 @@ -23,4 +27,3 @@ None; this package neither assembles nor sends a provider request. - **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/question cards are display-only placeholders** — web-side answering (composer takeover panel) is the P-II approvals project. -- **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. diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 51bdffb2d6..41256612d9 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -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:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 266602e1b2..36917d48c1 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -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(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,48 +42,46 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat * @param ctx - client root context. */ export function apply(ctx: Context): void { - const sessions = need(ctx, 'sessions') - const layout = need(ctx, 'layout') - const i18n = need(ctx, 'i18n') - const slots = need(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', - store: chat, - inject: (sessionId: SessionId, actions: BoundActions): ConversationInjected => { - const session = sessions.manager.get(sessionId) + children: { 'conversation.view': { kind: 'list', scope: 'session' } }, + store: chatStore, + inject: (sessionId: SessionId, actions: BoundActions): 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() @@ -107,19 +98,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): 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() }, }), @@ -128,7 +146,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) } diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index fd612990ce..7bb9a22e3e 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -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' diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b444e557ec..c68cf98571 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -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 -/** 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(() => ({ - 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 (
- + {renderSlot('conversation.chat.toolview', owner, { + entryKey: toolName, + fallback: , + })}
) }) /** 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) => ( } -/** - * 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 { - 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(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(null) + const openedRef = useRef(false) + const lastKeyRef = useRef(null) - const listRef = useRef(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(null) - const openedRef = useRef(false) - const lastKeyRef = useRef(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 ( - - ) - } - const node: ConversationNode = item.node - if (node.kind === 'assistant') { - return - } - /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */ - if (node.kind === 'tool-result') return null - return - } - - return ( -
-
-
- {openState === 'loading' &&
载入历史…
} - {openState === 'error' &&
历史加载失败:{openErrorMessage}
} - {hasMore && ( -
- -
- )} - {items.map(renderItem)} - - {runningCalls.length > 0 && ( -
- {runningCalls.map((call) => ( - - ))} -
- )} - {pending.map((item) => )} -
-
- {!atBottom && ( - - )} -
- ) + 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 ( + + ) + } + const node: ConversationNode = item.node + if (node.kind === 'assistant') { + return + } + /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */ + if (node.kind === 'tool-result') return null + return + } + + return ( +
+
+
+ {openState === 'loading' &&
载入历史…
} + {openState === 'error' &&
历史加载失败:{openErrorMessage}
} + {hasMore && ( +
+ +
+ )} + {items.map(renderItem)} + + {runningCalls.length > 0 && ( +
+ {runningCalls.map((call) => ( + + ))} +
+ )} + {pending.map((item) => )} +
+
+ + {!atBottom && ( + + )} +
+ ) } diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 958a90526f..9b507e0662 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -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, 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' @@ -22,8 +24,8 @@ const VARIANT_ICONS: Record = { others: , } -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 ( ) } diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index ebaa485117..d7211f2f91 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -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)((s) => s.nodes) +/** Props: the conversation-snapshot selector hook (handed down by ChatView). */ +export interface StatsLineProps { useSession: SnapshotSelectorHook } + +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[] = [] diff --git a/packages/client/ui-conversation/src/client/chat/ToolViewOutlet.tsx b/packages/client/ui-conversation/src/client/chat/ToolViewOutlet.tsx deleted file mode 100644 index 9f376f593a..0000000000 --- a/packages/client/ui-conversation/src/client/chat/ToolViewOutlet.tsx +++ /dev/null @@ -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, Map>() - -function cachedInject(inject: ToolViewInject, 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 - const Row = resolved.component - return ( - }> - {resolved.inject === undefined - ? - : } - - ) -} diff --git a/packages/client/ui-conversation/src/client/chat/register.ts b/packages/client/ui-conversation/src/client/chat/register.ts deleted file mode 100644 index b417ab4c0b..0000000000 --- a/packages/client/ui-conversation/src/client/chat/register.ts +++ /dev/null @@ -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 }, - }) -} diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a7001fb21e..eabe747f8f 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,31 +1,101 @@ /** - * 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 (framework standard kit) & PropsStore + * 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 (framework + * standard kit) & PropsRenderSlots (declared children) & PropsStore * (declared store's read/write faces) & the injected business face declared - * here. No renderSlot share: none of the three registrations declares - * children, so the zero-renderSlot inference applies. + * here. */ -import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' import type { 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: `. 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 } + } +} + +/** + * 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` 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 /** * 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 } @@ -33,17 +103,29 @@ 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 } -/** Full conversation-slot component props: runtime share & store share & injected share. */ +/** Full conversation-slot component props: runtime share & view-slot render share & store share & injected share. */ export type ConversationSlotProps = - PropsRuntime<'conversation'> & PropsStore & ConversationInjected + PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view'> & PropsStore & 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 & ChatViewInjected /** * Injected share of the details slot: the panel is otherwise a pure reader of diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index ea566eb248..1072b0cbbb 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -3,9 +3,12 @@ * 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). */ diff --git a/packages/client/ui-conversation/src/client/contract/toolview.ts b/packages/client/ui-conversation/src/client/contract/toolview.ts deleted file mode 100644 index f79478a57e..0000000000 --- a/packages/client/ui-conversation/src/client/contract/toolview.ts +++ /dev/null @@ -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 = (sessionId: SessionId) => I - -/** Options accepted by the toolview registry's register; `I` is inferred from the inject factory. */ -export interface ToolViewOptions { - /** Session filter; absent = global registration. */ - scope?: (sessionId: SessionId) => boolean - /** Private inject factory merged into the row's props by the render outlet. */ - inject?: ToolViewInject -} - -/** - * 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 { - component: FC - inject?: ToolViewInject -} - -/** 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 -} diff --git a/packages/client/ui-conversation/src/client/contract/views.ts b/packages/client/ui-conversation/src/client/contract/views.ts index e201b67e20..da573f007a 100644 --- a/packages/client/ui-conversation/src/client/contract/views.ts +++ b/packages/client/ui-conversation/src/client/contract/views.ts @@ -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 = - 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 = - 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 - -/** One registered conversation view (props positions keyed by the entry's declared shapes). */ -export interface ViewEntry { - id: Id - label: string - order?: number - component: FC> - /** Per-view chrome attachments (chat mounts the stats line as footer). */ - chrome?: { header?: FC>; footer?: FC> } -} - -/** 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 - 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 } diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 5605971c54..23215f17d8 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -1,34 +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' 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, ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps, - EmptyStateInjected, EmptyStateSlotProps, + ChatStore, ChatViewInjected, ChatViewSlotProps, 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 } } diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 9c9ee639b8..94ebd59628 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -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 - /** 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(entry: ViewEntry): () => 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() -} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index de970381ff..be37dfa9e8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -1,16 +1,17 @@ // 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). // 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 +36,15 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session } export function ConversationRoot({ - sessionId, useSession, useSessions, useStore, actions, - views, send, stop, openDetails, loadOlder, open, + sessionId, useSession, useSessions, useStore, actions, renderSlot, + 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) @@ -56,27 +57,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(() => ({ - sessionId, useSession, useStore, - actions: { openDetails, loadOlder }, - }), [sessionId, useSession, useStore, openDetails, loadOlder]) - - const renderView = (entry: ViewEntry): ReactNode => { - const Header = entry.chrome?.header - const Footer = entry.chrome?.footer - const View = entry.component - return ( - <> - {Header !== undefined &&
} - - {Footer !== undefined &&