Merge pull request #4758 from deepseek-harness/feat/chat-step-groups
feat(web): add definition-owned conversation groups
This commit is contained in:
commit
85f16860f5
51 files changed
+2095
-83
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md
|
||||
2026-09-21-conversation-build-groups.md: 6591ebea61d8edd06edbaf5f54f2bebd1bd00602
|
||||
2026-09-21-conversation-build-groups.zh.md: 70657ded8e04f0badb987ada965aef1184cfc7b0
|
||||
@@ -0,0 +1,155 @@
|
||||
# Agent Note: Definition-owned grouping with generic Node/Group assembly
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-21-conversation-build-groups.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Chat needs expandable groups for continuous process content while keeping replies, user input, and Turn controls independent. Compact, Detailed, and Expanded must share grouping results; changing modes must not rebuild React instances by removing group containers, moving member parents, or changing keys.
|
||||
|
||||
A Chat Builder aggregates one target's Nodes and indexes. Hardcoding a Chat process projector there makes the Builder own business segmentation, even if some caching moves into generic infrastructure. Business Definitions are the registration point for these rules.
|
||||
|
||||
Process groups are orthogonal to Steps: one Assistant Node can contribute reasoning inside a group and a reply outside it; tools after that reply enter a later group. Step-number ranges cannot describe membership. Existing event Definitions interpret one event at a time, whereas grouping needs the Nodes those Definitions have already interpreted.
|
||||
|
||||
## Decision
|
||||
|
||||
The grouping foundation provides a separate, Node-input `ConversationGroupDefinition`, its registry and Session-local context, generic Builder input/publication, keyed Group storage, and the two React branches. Production Chat segmentation and PR #4565 presentation are not registered by this foundation; the [business migration proposal](../../proposed/feature/2026-09-20-chat-work-details-migration.md) retains their requirements. The [subsystem reference](../../../../docs/subsystems/conversation.md#group-definitions) and [source types](../../../../packages/client/ui-conversation/src/client/contract/groups.ts) own the current API details.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
| Requirement | Consequence |
|
||||
|---|---|
|
||||
| Business grouping belongs to a registered Definition | Membership, segmentation, replies, steering, retries, summaries, and cache invalidation stay together. |
|
||||
| Builder and assembler do generic work | Supply inputs, dispatch Definitions, validate references, reuse identities, and publish; never construct a concrete grouping class. |
|
||||
| PR #4565 defines product behavior | Preserve its behavior without copying its invasive implementation. |
|
||||
| React has two root kinds | The ordered root contains NodeReference and GroupReference, not another layout object model. |
|
||||
| All modes share grouping | Mode is absent from Definition input, keys, and parent selection. |
|
||||
| No buildLayout | ConversationViewDefinition keeps its existing responsibilities. |
|
||||
| Foundation precedes business | No production Chat grouping, summaries, notifications, or footer changes in the foundation PR. |
|
||||
|
||||
### Related decisions
|
||||
|
||||
| Record | Relationship |
|
||||
|---|---|
|
||||
| [Business Node assembly](2026-08-09-client-conversation-node-assembly.md) | Retains event matching, Contexts, Locations, one business Node per Context, and target Builders; grouping adds another input category. |
|
||||
| [Presentation policy and grouping](../../proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md) | Retains Definition ownership, local subscriptions, and mode independence; replaces event folding and Step-range membership as the grouping mechanism. |
|
||||
| [Work-details migration](../../proposed/feature/2026-09-20-chat-work-details-migration.md) | Retains product scope and migration tracking; infrastructure alone does not complete those features. |
|
||||
|
||||
These records retain independent rationale and remain active. Cross-View navigation and `toolCallFocus` are outside this change; no View handles, Label Slots, or resource-navigation architecture is introduced.
|
||||
|
||||
## Definition input, state, and registration
|
||||
|
||||
`ConversationNodeDefinition` keeps `match/start/update/publication/buildLocationData/buildViewNode`. `ConversationViewDefinition` keeps `target/create/isActive/toolCallFocus`. Group business is registered through `ctx.uiConversation.groups.register(processGroupDefinition)`, not a callback on either existing Definition.
|
||||
|
||||
- One Group Definition per target owns the complete grouping semantics. Duplicate target registration fails rather than selecting a winner or stacking strategies.
|
||||
- Registration requires an existing View target, but never constructs a Builder. First activation checks the constructed Builder for `groupInput()`; missing input fails. Ungrouped targets do not require it or allocate Group state.
|
||||
- Each Session owns one derived context per registered Definition. `create()` initializes its State; it is not a `turn/start` event or an event Definition's unique start Match. Definition objects do not hold mutable cross-Session state.
|
||||
- `update(context, input)` returns the adopted State. `buildGroups(context)` materializes pending output without interpreting events again or advancing counts on repeated reads. Replacement input requires a complete root and group replacement so removed Nodes cannot leave dangling references; `null` on apply retains output.
|
||||
- `replace` supplies target order, timeline, and synchronous `readNode`, `readTurn`, and `readPosition` readers; `apply` also supplies projected previous/current Nodes, `changedTurns`, and `changedTurnOrders`. Readers are valid during the synchronous call only and must not be retained in State.
|
||||
- Node data stays in the target Node Store. Content-only updates preserve the order array. Previous values are captured before the Builder installs projected updates, including additional Nodes changed by target projections.
|
||||
- `ConversationLocationIndex` accumulates changed Turns from boundaries and Location data writes. Assembler passes one drained batch to every updated target, including a Turn ending without Node upserts. Direct ungrouped Builder callers may omit this field.
|
||||
- The target position index supplies `changedTurnOrders` for visible-key, owning-Turn, or immediate-neighbour changes, including both owners of a moved Node and Turns affected by adjacent unscoped Nodes. `readTurn` returns one Turn's visible keys; `readPosition` returns `GroupNodePosition` with the owning Turn and immediate previous/next keys. Turn-only reads omit interruptions by unscoped Nodes; neighbour facts let business segmentation retain them. The index provides positions, not grouping decisions.
|
||||
- The generic GroupDataMap associates target and Data across registration, storage, and reading. Undeclared targets have no group payload type; business consumers do not reconstruct summary types with unknown assertions.
|
||||
|
||||
## Node/Group references and updates
|
||||
|
||||
`NodeKey` names an existing Context Node; `GroupKey` identifies a group within a Session, target, and Definition. `NodeReference` and `GroupReference` have equal standing in `RenderEntry`. `GroupSnapshot<Data>` stores a group's immutable data and ordered NodeReference members separately from root order. Group creation adds neither Session events nor an execution lifecycle.
|
||||
|
||||
`groupPart` is a renderer-owned part such as reasoning or response; omission selects the whole Node. A part may also appear at the root. Part completeness and content ownership belong to the business renderer, not the framework. Group data describes status, counts, or summaries, without copying Node bodies, tool results, or mutable business State. A group has no renderer field, CSS, coordinates, React elements, navigation, or nested groups.
|
||||
|
||||
| Update | Meaning |
|
||||
|---|---|
|
||||
| Omit `entries` on apply input | Keep root order; replacement input requires the complete sequence. |
|
||||
| Supply `entries`, including `[]` | Replace the complete root sequence; an empty array clears it. |
|
||||
| `groups: { kind: 'replace', snapshots }` | Replace complete group records, removing omitted groups. |
|
||||
| `groups: { kind: 'apply', upserts, removes }` | Upsert complete snapshots and remove named GroupKeys only. |
|
||||
| Data-only change | Upsert the group with new data, reuse members, omit entries. |
|
||||
| Member-only change | Upsert that group with its complete new member array; no all-group replacement. |
|
||||
| Root-only change | Supply entries with empty apply upserts/removes. |
|
||||
| Dissolve a group | Replace root entries with its members and remove the GroupKey atomically. Source Nodes remain. |
|
||||
|
||||
`entries` and `groups` are one installation batch, not independent publication channels. Removing a member reference does not delete a Node. Upserts need snapshot contents; removals need only identity. React may remount a member after a real membership change, but mode switches must not dissolve groups.
|
||||
|
||||
The store validates the submitted final result before mutation: every root Group has exactly one record and root position; Node references exist; duplicate upserts/removals and simultaneous upsert/removal fail. Across all roots and members, each `(NodeKey, groupPart)` appears at most once. A whole Node cannot coexist with any of its parts, but different parts may occupy different positions. Atomic moves between positions are allowed.
|
||||
|
||||
GroupStore indexes records and sources by GroupKey, not repeated array searches. Equivalent root/member arrays and unchanged group snapshots retain identity. Data-only upserts inspect only submitted snapshots, without scanning roots, unchanged members, or Nodes. Full replacement revalidates references and still aligns results by key. Definition caches own business calculation; GroupStore owns installed results and notifications.
|
||||
|
||||
## Assembly order and lifecycle
|
||||
|
||||
1. BoundConversation receives append, prepend, replace, or Assistant settlement from the existing feed.
|
||||
2. The assembler runs existing Node Definition matching, state updates, and required Context replay.
|
||||
3. Existing immediate/animation-frame/none cadence schedules publication; grouping adds no timer or event subscription.
|
||||
4. Flush materializes Step then Turn Location data and target Nodes.
|
||||
5. Builder.replace/apply installs projected Nodes, target order, and indexes, records position changes, and retains GroupInput with its indexed readers without notifying independent sources.
|
||||
6. The assembler obtains the target's registered Group Definition context, calls update with builder.groupInput(), and adopts the returned State.
|
||||
7. It calls buildGroups, validates and installs the result, and installs the target snapshot.
|
||||
8. After every affected target is installed, it calls Builder.publish, publishes Group sources, and publishes Location data.
|
||||
9. BoundConversation publishes the existing root Conversation source.
|
||||
|
||||
First activation's replaceView uses the same target update function as ordinary flush. Inactive targets create neither Builder nor Group context. Registration rebuild takes the View and Group Definitions together, discards replaced/removed Group contexts, and clears old observed output. Surviving Definitions reuse keyed stores. React receives the Node Store identity so replacement rebinds keyed hooks without replacing keys or DOM instances.
|
||||
|
||||
Removing a registered View Definition pauses its grouping and clears derived output, while retaining the Group Definition registration. Restoring the View uses the existing replacement flow to rebuild from the current loaded timeline. Initial Group registration still rejects a missing View target; switching tabs does not unregister a View.
|
||||
|
||||
Grouping never replays raw events. Node Definitions handle replay first; grouping consumes the resulting target replacement or changes. Prepend may repair boundaries and legitimately move members. Definition handles old/new Locations and changed Turns instead of treating pagination or every content chunk as a complete-history regroup.
|
||||
|
||||
## Concrete implementation locations
|
||||
|
||||
| Layer/file | Responsibility |
|
||||
|---|---|
|
||||
| [groups.ts](../../../../packages/client/ui-conversation/src/client/contract/groups.ts) | Group Definition, input, references, updates, typed data map, and readers. |
|
||||
| [conversation.ts](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) | Optional Builder groupInput/publish and snapshot-store grouped reader; existing Node/View Definitions stay unchanged. |
|
||||
| [group-registry.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-registry.ts) | Existing registry base, target uniqueness, typed registration, effect/disposer lifecycle. |
|
||||
| [assembly.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembly.ts) | UiConversation.groups and existing binding rebuild notifications. |
|
||||
| [assembler.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembler.ts) | Common first-activation/flush dispatch, context ownership, installation, and publication. |
|
||||
| [location-index.ts](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) | Accumulate changed Turns, including lifecycle-only and Location-data changes. |
|
||||
| [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | Atomic reference validation, keyed sources, array reuse, and local publication. |
|
||||
| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | Record projected Node deltas, target positions, and changed Turn orders; provide indexed readers and defer source notification. No process grouping class. |
|
||||
| [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | Read optional root entries and switch between node/group; fallback to existing Node order. |
|
||||
| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | Stable group parent, member-only subscription, nested Node seats. |
|
||||
| [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | Existing Node sources/renderers, groupPart forwarding, distinct part anchors, Store-replacement rebind. |
|
||||
| [slots.ts](../../../../packages/client/ui-chat/src/client/contract/slots.ts) and [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts) | Existing keyed-hook injection for Group sources; no Slot-engine change. |
|
||||
|
||||
## React reading and presentation
|
||||
|
||||
The root selects `views.grouped('chat')?.entries` through the existing useConversation source. Keyed Group hooks use existing injection; each Group seat selects members. Members use existing keyed Node sources. Removed Group sources can read undefined before the root removes their components, so selectors tolerate absence and keep Hook order stable.
|
||||
|
||||
React keys derive from reference kind, NodeKey/groupPart or GroupKey, using unambiguous tuples. No separate RenderKey type or renderer dispatch field is needed. Compact, Detailed, and Expanded keep the same group container and member parents; presentation changes do not rerun the Group Definition or select a different root branch.
|
||||
|
||||
The stable Group parent is a `div` with `display: contents`, without its own layout box. CSS inheritance remains available; business styles adapt child/sibling selectors and own measurable body containers. Neither CSS variables nor geometry enter the Definition. Reading-position capture measures member Nodes and retains part-specific anchors; existing Turn navigation resolves the original NodeKey to its first visible part.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Builder-owned buildGroups or process projector.** Rejected: the target aggregator would still own business rules. Moving caches into infrastructure does not change that ownership.
|
||||
|
||||
**Add buildGroups to the existing event Definition without new input.** Rejected: the callback name does not provide cross-Node input, lifecycle changes, or reconstruction semantics.
|
||||
|
||||
**Replay raw events in the Group Definition.** Rejected: it duplicates Assistant/Tool/Message interpretation and can diverge from the Builder's final visible order.
|
||||
|
||||
**ConversationViewDefinition.buildLayout.** Rejected: it broadens a target factory into a business layout callback rather than registering a distinct business Definition.
|
||||
|
||||
**One event Context per Group.** Rejected: boundaries depend on preceding visible content, not a standalone event carrying the group's start identity. A target-local derived context also admits independent Nodes outside Turns.
|
||||
|
||||
**Dependency graphs, ViewItem classes, factories, nested groups, or strategy stacking.** Rejected for this phase: one target input and one non-nested grouping output cover the stated need.
|
||||
|
||||
**Separate data-only mutation operation.** Rejected: an immutable snapshot upsert with reused members already updates summaries without replacing other groups.
|
||||
|
||||
**Let Group Definition add/remove both Node and Group entities.** Rejected: Node data already has an owner. Symmetrical references do not require a second Node owner.
|
||||
|
||||
**Remove the group wrapper in Expanded.** Rejected: changing the parent remounts members even when their keys survive.
|
||||
|
||||
## Verification
|
||||
|
||||
- [Group store tests](../../../../packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts) cover atomic reference validation, root/member identity reuse, local publication, and removal without deleting source Nodes.
|
||||
- [Grouping dispatch tests](../../../../packages/client/ui-conversation/tests/conversation-groups.client.spec.ts) cover first activation, lifecycle-only input, registry replacement, View removal/recovery, and complete replacement output. [Assembler tests](../../../../packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts) cover changed-Turn reporting and Location data sources.
|
||||
- [Node source tests](../../../../packages/client/ui-chat/tests/chat-node-source.client.spec.ts) cover projected grouping inputs, indexed readers, and empty change batches.
|
||||
- [Chat rendering tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) retain component state across modes, rebind replacement Node stores, pass independent parts, and omit unreferenced Nodes. [Viewport tests](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts) cover grouped reading anchors, history prepend, and part-aware Turn navigation.
|
||||
|
||||
## Consequences
|
||||
|
||||
- This is an explicit framework extension: a new input protocol, registry, context, publication phase, and reader. It is not merely another callback.
|
||||
- Target-local State does not automatically make business updates local. Node changes and changed Turn orders let a Definition choose affected groups and ranges. Structural changes still rebuild the target position index over visible order; content-only updates do not rebuild it. Structural grouping output still replaces the complete root-reference array.
|
||||
- Node order and visibility have one owner: the Builder. Grouping must not independently sort raw events or infer membership again in infrastructure.
|
||||
- Stable mounting does not eliminate layout, paint, or retained-memory costs. No measured latency or optimal-performance claim is made.
|
||||
- Real group splits, merges, first-member changes, and pagination repairs can change identity. The mode-switch guarantee does not prohibit those legitimate changes.
|
||||
- Revealing hidden parts, selection/copy, interruption notices, group disclosure, and outer Turn interaction require business adaptation. Foundation tests exercise mixed references without enabling production groups.
|
||||
@@ -0,0 +1,155 @@
|
||||
# Agent Note: Definition 拥有分组业务,框架统一调度 Node/Group 输出
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-21-conversation-build-groups.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Chat 需要把连续过程内容收进可展开的组,同时保留独立回复、用户输入和轮次控件。Compact、Detailed、Expanded 必须共用分组结果;切换模式不能通过删除组容器、移动成员父级或更换 key 重建 React 实例。
|
||||
|
||||
Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat 过程投影器,即使把部分缓存移到通用层,仍然让 Builder 拥有业务分段。业务 Definition 才是这些规则的注册点。
|
||||
|
||||
过程组与 Step 正交:同一个 Assistant Node 可以把推理放在组内、回复放在组外,回复后的工具进入后面的组。Step 编号范围无法描述这种成员归属。现有事件 Definition 逐个解释事件,分组则需要这些 Definition 已经解释完成的节点。
|
||||
|
||||
## Decision
|
||||
|
||||
分组基础机制提供独立的节点输入 `ConversationGroupDefinition`、注册表及 Session 内上下文、Builder 通用输入与发布、按键 Group 存储及两种 React 分支。基础机制不注册真实 Chat 分段及 PR #4565 展示,[业务迁移提案](../../proposed/feature/2026-09-20-chat-work-details-migration.zh.md)保留这些需求。[子系统参考](../../../../docs/subsystems/conversation.zh.md#group-definitions)与[源码类型](../../../../packages/client/ui-conversation/src/client/contract/groups.ts)拥有当前 API 细节。
|
||||
|
||||
### 职责
|
||||
|
||||
| 要求 | 含义 |
|
||||
|---|---|
|
||||
| 分组业务属于已注册 Definition | 成员、分段、回复、steering、重试、摘要和缓存失效放在一起。 |
|
||||
| Builder 与 assembler 做统一工作 | 提供输入、调度 Definition、校验引用、复用身份并发布,不创建具体分组类。 |
|
||||
| 产品行为以 PR #4565 为准 | 保留行为,不复制其侵入性实现。 |
|
||||
| React 根循环只有两种 kind | 根顺序包含 NodeReference 和 GroupReference,不引入另一套布局对象模型。 |
|
||||
| 所有模式共用分组 | 模式不进入 Definition 输入、key 或父级选择。 |
|
||||
| 不采用 buildLayout | ConversationViewDefinition 保留现有职责。 |
|
||||
| 先基础、后业务 | 基础 PR 不包含真实 Chat 分组、摘要、通知或页脚变化。 |
|
||||
|
||||
### 与已有记录的关系
|
||||
|
||||
| 记录 | 关系 |
|
||||
|---|---|
|
||||
| [业务节点组装](2026-08-09-client-conversation-node-assembly.zh.md) | 保留事件匹配、Context、Location、每个 Context 一个业务 Node 及目标 Builder,分组增加另一种输入类别。 |
|
||||
| [展示策略与过程分组](../../proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md) | 保留 Definition 归属、局部订阅和模式无关要求,以新分组机制替代事件 fold 及 Step 范围成员推断。 |
|
||||
| [工作过程展示迁移](../../proposed/feature/2026-09-20-chat-work-details-migration.zh.md) | 保留产品范围及迁移跟踪,基础机制本身不代表这些功能完成。 |
|
||||
|
||||
这些记录仍有独立理由,保留有效状态。跨 View 导航与 `toolCallFocus` 不属于本次改动;不引入 View 句柄、Label Slot 或资源导航架构。
|
||||
|
||||
## Definition 输入、状态与注册
|
||||
|
||||
`ConversationNodeDefinition` 保留 `match/start/update/publication/buildLocationData/buildViewNode`,`ConversationViewDefinition` 保留 `target/create/isActive/toolCallFocus`。分组业务通过 `ctx.uiConversation.groups.register(processGroupDefinition)` 注册,不加在上述已有 Definition 的回调中。
|
||||
|
||||
- 每个目标只有一个 Group Definition,拥有完整分组语义。重复目标注册报错,不选择胜者,也不叠加策略。
|
||||
- 注册要求已有 View 目标,但不构造 Builder。首次激活取得 Builder 后检查 `groupInput()`,缺少输入方法时报错。未分组目标不要求该方法,也不分配 Group 状态。
|
||||
- 每个 Session 为已注册 Definition 拥有一个派生上下文。`create()` 初始化 State,不是 `turn/start` 事件,也不是事件 Definition 的唯一 start Match。Definition 对象不保存跨 Session 的可变状态。
|
||||
- `update(context, input)` 返回框架采纳的 State。`buildGroups(context)` 物化待输出结果,不重新解释事件,不因重复读取增加计数。替换输入要求输出完整根序列和组替换,避免已移除节点留下悬挂引用;apply 的 `null` 保留结果。
|
||||
- `replace` 提供目标顺序、时间线及同步的 `readNode`、`readTurn`、`readPosition` 读取器;`apply` 还提供投影后的节点前后值、`changedTurns` 和 `changedTurnOrders`。读取器仅在当前同步调用中有效,不保留到 State。
|
||||
- 节点数据仍在目标 Node Store。仅正文更新保留顺序数组;旧值在 Builder 安装投影结果前记录,也覆盖目标投影额外改变的节点。
|
||||
- `ConversationLocationIndex` 从边界事件和 Location 数据写入累计变化轮次。assembler 将同一批变化交给全部被更新目标,覆盖没有节点 upsert 的 Turn 结束。直接调用未分组 Builder 的调用方可省略该字段。
|
||||
- 目标位置索引通过 `changedTurnOrders` 提供可见键、所属 Turn 或紧邻关系的变化,包含移动 Node 的新旧所属轮次,以及受相邻轮次外 Node 影响的轮次。`readTurn` 返回某个 Turn 的可见键,`readPosition` 返回包含所属 Turn 和前后紧邻键的 `GroupNodePosition`。仅按 Turn 读取会遗漏轮次外 Node 造成的分隔,业务分段可以通过相邻事实保留这些分隔。索引提供位置,不决定分组。
|
||||
- 通用 GroupDataMap 在注册、存储及读取之间关联目标和 Data。未声明的目标没有组载荷类型,业务消费方不通过 unknown 断言恢复摘要类型。
|
||||
|
||||
## Node/Group 引用与更新
|
||||
|
||||
`NodeKey` 表示既有 Context Node,`GroupKey` 在 Session、目标及 Definition 内标识组。`NodeReference` 与 `GroupReference` 在 `RenderEntry` 中地位相同。`GroupSnapshot<Data>` 单独保存组的不可变数据与有序 NodeReference 成员,不混入根顺序。创建 Group 不增加 Session 事件或执行生命周期。
|
||||
|
||||
`groupPart` 是渲染器拥有的节点部分,例如 reasoning 或 response,省略表示整个 Node。部分引用也可以独立位于根层。部分内容的完备性和归属由业务渲染器负责,不由框架判断。组数据描述状态、计数或摘要,不复制 Node 正文、工具结果或可变业务 State。组不携带 renderer 字段、CSS、坐标、React 元素、导航或嵌套组。
|
||||
|
||||
| 更新 | 含义 |
|
||||
|---|---|
|
||||
| apply 输入省略 `entries` | 保留根顺序;替换输入必须提供完整序列。 |
|
||||
| 提供 `entries`,包括 `[]` | 替换完整根序列,空数组清空根序列。 |
|
||||
| `groups: { kind: 'replace', snapshots }` | 替换完整组记录,移除未包含的旧组。 |
|
||||
| `groups: { kind: 'apply', upserts, removes }` | upsert 完整快照,只按具名 GroupKey 删除组。 |
|
||||
| 仅数据变化 | upsert 新 data,复用 members,省略 entries。 |
|
||||
| 仅成员变化 | upsert 该组完整的新成员数组,不替换全部组。 |
|
||||
| 仅根顺序变化 | 提供 entries,apply 的 upserts/removes 为空。 |
|
||||
| 解散一个组 | 原子地用成员替换根引用并删除 GroupKey,保留原 Node。 |
|
||||
|
||||
`entries` 与 `groups` 属于同一安装批次,不是独立发布通道。删除成员引用不删除 Node。upsert 需要快照内容,删除只需要身份。真实成员归属变化可以让 React 重挂载成员,但模式切换不能解散组。
|
||||
|
||||
存储在修改前校验最终提交结果:每个根 Group 恰有一份记录和一个根位置;Node 引用存在;重复 upsert、重复删除及同时 upsert/删除均报错。全部根和成员中,每个 `(NodeKey, groupPart)` 最多出现一次。整 Node 不能与自身任一部分共存,不同部分可以占据不同位置。允许引用在一次原子更新中移动位置。
|
||||
|
||||
GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价根数组、成员数组及未变化组快照保留身份。仅数据 upsert 只检查提交的快照,不扫描根、未变化成员或 Node。完整替换重新校验引用,仍按 key 对齐结果。Definition 缓存拥有业务计算,GroupStore 拥有已安装结果及通知。
|
||||
|
||||
## 组装时序与生命周期
|
||||
|
||||
1. BoundConversation 从现有 feed 接收 append、prepend、replace 或 Assistant settlement。
|
||||
2. assembler 运行既有 Node Definition 匹配、状态更新及必要的 Context 重放。
|
||||
3. 既有 immediate/animation-frame/none 节奏调度发布,分组不增加计时器或事件订阅。
|
||||
4. flush 依次物化 Step、Turn Location 数据及目标 Node。
|
||||
5. Builder.replace/apply 安装投影后 Node、目标顺序及索引,记录位置变化,保留包含索引读取器的 GroupInput,不通知独立来源。
|
||||
6. assembler 取得目标已注册的 Group Definition 上下文,以 builder.groupInput() 调用 update,采纳返回 State。
|
||||
7. 调用 buildGroups,校验并安装结果,安装目标快照。
|
||||
8. 全部受影响目标安装完成后,调用 Builder.publish,发布 Group 来源和 Location 数据。
|
||||
9. BoundConversation 发布既有根 Conversation 来源。
|
||||
|
||||
首次激活的 replaceView 与普通 flush 使用同一目标更新函数。未激活目标不创建 Builder 或 Group 上下文。注册重建一并采纳 View 和 Group Definition,丢弃被替换或删除的 Group 上下文并清空旧观察结果。未替换的 Definition 复用按键存储。React 接收 Node Store 身份,让存储替换时重新绑定按键钩子,不替换 key 或 DOM 实例。
|
||||
|
||||
已注册的 View Definition 被移除后,分组计算暂停并清空派生结果,Group Definition 注册仍保留。View 恢复时沿现有替换流程,从当前已加载时间线重建。首次注册 Group 时仍拒绝缺失的 View 目标;切换页签不会注销 View。
|
||||
|
||||
分组不重放原始事件。Node Definition 先处理重放,分组消费其最终目标替换或变化。prepend 可以修正边界并真实移动成员。Definition 处理新旧 Location 和变化轮次,不把分页或每个正文分片都当作完整历史重新分组。
|
||||
|
||||
## 具体实现落点
|
||||
|
||||
| 层/文件 | 职责 |
|
||||
|---|---|
|
||||
| [groups.ts](../../../../packages/client/ui-conversation/src/client/contract/groups.ts) | Group Definition、输入、引用、更新、类型数据映射及读取器。 |
|
||||
| [conversation.ts](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) | Builder 可选 groupInput/publish 和快照存储 grouped 读取,已有 Node/View Definition 不变。 |
|
||||
| [group-registry.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-registry.ts) | 复用注册表基类,目标唯一性、类型化注册及 effect/disposer 生命周期。 |
|
||||
| [assembly.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembly.ts) | UiConversation.groups 与现有 binding 重建通知。 |
|
||||
| [assembler.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembler.ts) | 首次激活和 flush 共用调度,上下文归属、安装及发布。 |
|
||||
| [location-index.ts](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) | 累积变化轮次,覆盖仅生命周期和 Location 数据变化。 |
|
||||
| [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | 原子引用校验、按键来源、数组复用及局部发布。 |
|
||||
| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | 记录投影后节点增量、目标位置及变化轮次顺序,提供索引读取器并推迟来源通知,不持有过程分组类。 |
|
||||
| [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | 读取可选根引用并切换 node/group,无分组时使用既有 Node 顺序。 |
|
||||
| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | 稳定组父级、仅成员订阅及嵌套 Node 容器。 |
|
||||
| [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | 既有 Node 来源及渲染器、groupPart 传递、独立部分锚点及 Store 替换时重绑定。 |
|
||||
| [slots.ts](../../../../packages/client/ui-chat/src/client/contract/slots.ts) 与 [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts) | 使用现有按键钩子注入 Group 来源,不改 Slot 引擎。 |
|
||||
|
||||
## React 读取与展示
|
||||
|
||||
根层通过既有 useConversation 来源选择 `views.grouped('chat')?.entries`。按键 Group 钩子沿用现有注入,Group 容器选择 members。成员使用既有按键 Node 来源。根列表移除组件前,已删除 Group 来源可能先返回 undefined,因此选择器允许不存在,并保持钩子顺序稳定。
|
||||
|
||||
React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派生,不另设 RenderKey 类型或 renderer 分发字段。Compact、Detailed、Expanded 保留同一组容器及成员父级,展示变化不重新运行 Group Definition,也不选择不同的根分支。
|
||||
|
||||
稳定 Group 父级使用 `div` 与 `display: contents`,自身不产生布局盒子。CSS 继承仍然可用,业务样式适配子级/兄弟选择器并拥有可测量的正文容器。CSS 变量和几何信息都不进入 Definition。阅读位置采样测量成员 Node,保留部分专属锚点;既有轮次导航将原 NodeKey 解析到它的第一个可见部分。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Builder 持有 buildGroups 或过程投影器。** 不采用,目标汇总器仍然拥有业务规则,把缓存移到通用层不能改变归属。
|
||||
|
||||
**在原事件 Definition 加 buildGroups,不补输入。** 不采用,回调名称不能提供跨节点输入、生命周期变化或重建语义。
|
||||
|
||||
**Group Definition 重放原始事件。** 不采用,它重复 Assistant、Tool、Message 解释,并可能偏离 Builder 最终可见顺序。
|
||||
|
||||
**ConversationViewDefinition.buildLayout。** 不采用,它把目标工厂扩展成业务布局回调,没有独立注册业务 Definition。
|
||||
|
||||
**每个 Group 一个事件 Context。** 不采用,边界取决于前方可见内容,不由一个携带组起点身份的事件决定。目标内派生上下文也能处理 Turn 外独立节点。
|
||||
|
||||
**依赖图、ViewItem 类、工厂、嵌套组或策略叠加。** 本阶段不采用,一个目标输入和一份非嵌套分组输出覆盖当前诉求。
|
||||
|
||||
**独立的数据更新操作。** 不采用,复用 members 的不可变快照 upsert 已经能更新摘要,不替换其他组。
|
||||
|
||||
**Group Definition 同时增删 Node 和 Group 实体。** 不采用,Node 数据已有所有者,引用对称不需要第二个 Node 所有者。
|
||||
|
||||
**Expanded 移除组容器。** 不采用,即使 key 不变,父级改变仍会重挂载成员。
|
||||
|
||||
## Verification
|
||||
|
||||
- [Group 存储测试](../../../../packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts)覆盖原子引用校验、根与成员身份复用、局部发布,以及删除组但保留原 Node。
|
||||
- [分组调度测试](../../../../packages/client/ui-conversation/tests/conversation-groups.client.spec.ts)覆盖首次激活、仅生命周期输入、注册替换、View 移除与恢复,以及完整替换输出。[Assembler 测试](../../../../packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts)覆盖变化轮次报告和 Location 数据来源。
|
||||
- [Node 来源测试](../../../../packages/client/ui-chat/tests/chat-node-source.client.spec.ts)覆盖投影后的分组输入、索引读取器和空变化批次。
|
||||
- [Chat 渲染测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx)保留模式切换时的组件状态,重绑定替换后的 Node 存储,传递独立部分,并省略未引用 Node。[视口测试](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts)覆盖组内阅读锚点、历史前插及部分感知的轮次导航。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 这是明确的框架扩展,包含新输入协议、注册表、上下文、发布阶段及读取器,不只是增加一个回调。
|
||||
- 目标内 State 不会自动让业务更新局部化,Definition 可以根据节点变化和轮次顺序变化选择受影响的组及范围。结构变化仍按可见顺序重建目标位置索引,仅正文更新不重建该索引。结构性分组输出仍替换完整根引用数组。
|
||||
- Node 顺序及可见性只有 Builder 一个所有者,分组不独立排序原始事件,也不在通用层再次推断成员。
|
||||
- 稳定挂载不消除布局、绘制或保留内存的成本,不宣称实测延迟或最优性能。
|
||||
- 真实组拆并、首成员变化及分页修正可以改变身份,模式切换保证不禁止这些正常变化。
|
||||
- 隐藏部分的揭示、选择复制、中断提示、组开合与外层 Turn 联动需要业务适配。基础测试使用混合引用,不启用真实分组。
|
||||
+2
-2
@@ -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 .agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md
|
||||
2026-09-20-chat-presentation-policy-and-step-groups.md: df0397a0568967c920994192b370e9fe1d063229
|
||||
2026-09-20-chat-presentation-policy-and-step-groups.zh.md: 1bf9cf95174a1de0c7d0576bdd9fbb842d980ae7
|
||||
2026-09-20-chat-presentation-policy-and-step-groups.md: 65fbb313d57a34a792ed2ec08d0adfb2fdd5f03d
|
||||
2026-09-20-chat-presentation-policy-and-step-groups.zh.md: 7ac3442476ea34d5127376cfc40d42892403ce47
|
||||
+2
@@ -25,6 +25,8 @@ The three settings, presentation policy channel, and per-Turn folding eligibilit
|
||||
|
||||
## Goals and constraints
|
||||
|
||||
The [implemented Definition-owned Build Group foundation](../../implemented/architecture/2026-09-21-conversation-build-groups.md) supersedes this note's event fold, Builder-materialized group headers, Step-range membership, and changedLocationData proposal. This note retains the presentation-policy and per-Turn eligibility rationale; the grouping implementation below records the superseded route, not the current implementation plan.
|
||||
|
||||
The following decisions were confirmed during design review and are inputs to this proposal.
|
||||
|
||||
| Number | Decision | Meaning |
|
||||
|
||||
+2
@@ -25,6 +25,8 @@ Chat 的「对话显示」设置在本提案开始时只有两档:`normal` 不
|
||||
|
||||
## 目标与约束
|
||||
|
||||
[已实现的 Definition 分组基础机制](../../implemented/architecture/2026-09-21-conversation-build-groups.zh.md)取代本记录中的事件 fold、Builder 物化组头、Step 范围成员推断及 changedLocationData 提案。本记录保留展示策略与按轮折叠资格的理由;下文分组实现记录已被取代的路线,不是当前实施方案。
|
||||
|
||||
下表是评审阶段已经确认的裁决,本方案不再重新讨论它们。
|
||||
|
||||
| 编号 | 裁决 | 含义 |
|
||||
|
||||
@@ -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 .agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md
|
||||
2026-09-20-chat-work-details-migration.md: cd5dbe723425d5bed719bbb579305e3f917d0cc5
|
||||
2026-09-20-chat-work-details-migration.zh.md: 4055b027fc7e5c710358886361badb88a4e9b13e
|
||||
2026-09-20-chat-work-details-migration.md: 126b61d32d4a4dbf27cb5997149abc9820d7f881
|
||||
2026-09-20-chat-work-details-migration.zh.md: 20727c299307881b67d0662092d269afa9af176c
|
||||
@@ -45,7 +45,7 @@ Verify staging against the feature's diff hunks, not whole files. Inspect the wo
|
||||
| Local reasoning-preview subscription | `ReasoningRow` selects its own visibility | Same | Same | Complete | Existing capability | Committed | Pending |
|
||||
| Stable disclosure-row references | Callbacks, icons, and unchanged summaries retain references; full Markdown mounts only on expansion | Same | Same | Complete | Existing capability | Committed | Pending |
|
||||
|
||||
All three modes retain whole-Turn folding. `expanded` means removing secondary process groups, not disabling whole-Turn folding or automatically expanding reasoning and tool-card bodies. Until grouping is connected, Chat presents `detailed` and `expanded` identically.
|
||||
All three modes retain whole-Turn folding. `expanded` removes group-level folding, headers, and height limits while retaining group identity and member parents; it does not disable whole-Turn folding or automatically expand reasoning and tool-card bodies. Until grouping is connected, Chat presents `detailed` and `expanded` identically.
|
||||
|
||||
### Migration inventory without grouping dependencies
|
||||
|
||||
@@ -82,11 +82,11 @@ Review batches are independent of implementation and staging status. Review rema
|
||||
|
||||
### Requirements retained for the grouping phase
|
||||
|
||||
All items below remain incomplete and retain their full requirements for the turn/step and grouping phase. Neither this channel nor the transferred drafts introduce step-group. Experimental code in `feat/chat-step-groups` does not establish completion of these product behaviors or performance requirements.
|
||||
All items below remain incomplete and retain their full requirements for the turn/step and grouping phase. The [Definition-owned grouping foundation](../../implemented/architecture/2026-09-21-conversation-build-groups.md) is implemented; that does not establish completion of these product behaviors or performance requirements.
|
||||
|
||||
| Feature | compact | detailed | expanded | Current state |
|
||||
|---|---|---|---|---|
|
||||
| Consecutive process groups between replies | Group tools and reasoning, initially collapsed | Same | Show process rows directly without secondary wrappers | Incomplete (grouping phase) |
|
||||
| Consecutive process groups between replies | Group tools and reasoning, initially collapsed | Same | Show process rows without group-level folding; retain member parents | Incomplete (grouping phase) |
|
||||
| Independent reasoning/reply visibility within one Assistant | Reasoning may join the preceding group; reply stays visible | Same | No secondary group hiding | Incomplete; do not directly copy the original split into two render nodes |
|
||||
| Header category summary | Current category while running; aggregate main categories after completion | Same | No header | Incomplete (grouping phase) |
|
||||
| Running header details | Hidden | Show command, path, query, etc.; reasoning summary when no tool is running | No header | Incomplete (grouping phase) |
|
||||
@@ -97,6 +97,38 @@ All items below remain incomplete and retain their full requirements for the tur
|
||||
|
||||
Grouping details to verify: completed summaries aggregate at most three categories and use “etc.” beyond three; call counts determine category ordering rather than appearing as header numbers. Detailed-mode live details collapse whitespace and are capped at 160 characters. Titles remain for at least 150ms; retain only the latest update without replaying queued updates. Expanded height is at most the smaller of 400px and half the viewport. Scrollable edges use a 24px fade and preserve interaction with outer scrolling. Retry rows are outside secondary groups. These values describe the original PR's reference behavior, not completed grouping experiments.
|
||||
|
||||
### Definition-owned segmentation and presentation
|
||||
|
||||
Business State accepts each input, clears pending changes, and returns repeatable output. Replace builds the loaded window; apply separates structural changes from content updates and maintains affected segments or Turn-local indexes. It owns group opening/closing, stable keys, summaries, and invalidation; generic infrastructure never corrects those decisions by Node kind or Step number.
|
||||
|
||||
| Ordered visible input | Business action |
|
||||
|---|---|
|
||||
| Owning Turn changes | Close the pending group. |
|
||||
| No Turn ownership | Close the group and emit an independent Node; never merge across it. |
|
||||
| turn-process control | Emit independently without closing the pending process. |
|
||||
| User, steering, trigger, retry, error, max-tokens, or tail | Close the group and emit independently. |
|
||||
| Assistant reasoning | Append the reasoning part to the pending group. |
|
||||
| Assistant visible reply | Close the group and emit the response part independently. Later tools enter a later group. |
|
||||
| Other process Nodes | Append the Node to the pending group; an ordinary Step boundary does not close it. |
|
||||
| Turn end | Close that Turn's pending groups even without Node upserts. |
|
||||
|
||||
Content growth with unchanged classification does not resegment history; it may update only the current summary. A first reply, new tool, steering, or retry can change structure. Visibility/location repair handles both old and new ownership. Real structural changes may linearly merge root references; the interface does not promise all operations are constant-time.
|
||||
|
||||
Group headers select data independently from member arrays. All modes retain the same Group container and member parents:
|
||||
|
||||
| Mode | Group header | Body |
|
||||
|---|---|---|
|
||||
| Compact | Short activity summary | Group open/closed state controls visibility. |
|
||||
| Detailed | Summary plus live command/path/query details | Same group open/closed state. |
|
||||
| Expanded | Hidden | Visible without group-level collapse or height cap; wrapper remains. |
|
||||
|
||||
This assumes the outer Turn process is open. Expanded retains whole-Turn folding and individual tool/reasoning disclosure; it must not eagerly mount all full Markdown. Turn-close resets of internal state are explicit business actions, not key changes. Group headers, scrolling/fades, and Turn interaction belong to this business integration.
|
||||
|
||||
| Business implementation | Responsibility |
|
||||
|---|---|
|
||||
| process-groups.ts and register.ts | Definition-owned segmentation, membership, caches, summaries, and registration. |
|
||||
| AssistantNodeView, Group seat, stores, navigation | Interpret parts; group header/body presentation; local open state; Turn/group reveal, selection/copy and interruption ownership. |
|
||||
|
||||
## Performance constraints
|
||||
|
||||
| Scenario | Property to preserve | Required evidence |
|
||||
|
||||
@@ -45,7 +45,7 @@ PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发
|
||||
| 推理预览的局部订阅 | `ReasoningRow` 选择自己的可见性 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
|
||||
| 折叠行引用稳定 | 回调、图标及未变化的摘要保持引用;完整 Markdown 仅在展开时挂载 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
|
||||
|
||||
三档都保留整轮折叠。`expanded` 的产品含义是取消二级过程分组,并非禁止整轮折叠,也不是自动展开每个思考或工具卡的正文。分组尚未接入时,`detailed` 与 `expanded` 的 Chat 展示相同。
|
||||
三档都保留整轮折叠。`expanded` 取消组级折叠、组头和限高,保留组身份与成员父级;并非禁止整轮折叠,也不是自动展开每个思考或工具卡的正文。分组尚未接入时,`detailed` 与 `expanded` 的 Chat 展示相同。
|
||||
|
||||
### 不依赖分组的迁移清单
|
||||
|
||||
@@ -82,11 +82,11 @@ PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发
|
||||
|
||||
### 分组阶段保留的需求
|
||||
|
||||
这些项目均未完成,继续保留全部需求,后续在 turn/step 与分组阶段处理。当前通道和本次搬迁草稿均未引入 step-group;`feat/chat-step-groups` 的实验代码不代表这些产品行为或性能要求已经完成。
|
||||
这些项目均未完成,继续保留全部需求,后续在 turn/step 与分组阶段处理。[Definition 拥有分组的基础机制](../../implemented/architecture/2026-09-21-conversation-build-groups.zh.md)已经实现,但不代表这些产品行为或性能要求已经完成。
|
||||
|
||||
| 功能 | compact | detailed | expanded | 当前状态 |
|
||||
|---|---|---|---|---|
|
||||
| 回复之间的连续过程分组 | 工具与推理归组,默认收起 | 相同 | 过程行直接展示,不使用二级分组包装 | 未完成(分组阶段) |
|
||||
| 回复之间的连续过程分组 | 工具与推理归组,默认收起 | 相同 | 过程行不做组级折叠,保留成员父级 | 未完成(分组阶段) |
|
||||
| 同一 Assistant 中推理与回复的分别显隐 | 推理可归入前组,回复外露 | 相同 | 不做二级分组隐藏 | 未完成;不直接复制原版拆成两个渲染节点的实现 |
|
||||
| 组头类别摘要 | 运行时显示当前类别,完成后聚合主要类别 | 相同 | 无组头 | 未完成(分组阶段) |
|
||||
| 运行中组头详情 | 不显示 | 显示命令、路径、查询等详情,无运行工具时显示推理摘要 | 无组头 | 未完成(分组阶段) |
|
||||
@@ -97,6 +97,38 @@ PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发
|
||||
|
||||
分组产品细节的核对项:完成摘要最多聚合前三类,超过三类使用「等」;调用计数用于分类排序,不直接显示为组头数字。详细模式的实时详情合并空白并限制为 160 个字符;标题最短展示时间为 150ms,只保留最新更新,不补播积压项。展开区高度不超过 400px 与半屏中的较小值,可滚动方向显示 24px 渐隐,并保留与外层滚动的衔接。重试行不属于二级过程组。这些数值是原 PR 的行为参考,不代表当前分组实验已经实现。
|
||||
|
||||
### Definition 拥有的分段与展示
|
||||
|
||||
业务 State 接受输入、清空待输出变化并返回可重复读取的结果。replace 构建已加载窗口;apply 区分结构变化和正文更新,维护受影响段或轮次内索引。它拥有开组、关组、稳定 key、摘要和失效规则;通用层不按 Node kind 或 Step 编号再次修正业务结论。
|
||||
|
||||
| 有序可见输入 | 业务动作 |
|
||||
|---|---|
|
||||
| 所属 Turn 改变 | 关闭待完成组。 |
|
||||
| 没有 Turn 归属 | 关闭组并输出独立 Node,不跨越它合并。 |
|
||||
| turn-process 控件 | 独立输出,不关闭待完成过程。 |
|
||||
| 用户、steering、触发、重试、错误、最大 token 或尾部 | 关闭组并独立输出。 |
|
||||
| Assistant 推理 | 把 reasoning 部分加入待完成组。 |
|
||||
| Assistant 可见回复 | 关闭组,独立输出 response 部分;后续工具进入后面的组。 |
|
||||
| 其他过程 Node | 加入待完成组,普通 Step 边界不关闭组。 |
|
||||
| Turn 结束 | 即使没有节点 upsert,也关闭该轮次待完成的组。 |
|
||||
|
||||
分类未变的正文增长不重新分段历史,必要时只更新当前摘要。首次回复、新工具、steering 或重试可以改变结构。可见性或位置修正同时处理旧、新归属。真实结构变化允许线性合并根引用,接口不承诺全部操作常数时间。
|
||||
|
||||
组头独立于成员数组选择 data,所有模式保留同一 Group 容器及成员父级:
|
||||
|
||||
| 模式 | 组头 | 正文 |
|
||||
|---|---|---|
|
||||
| Compact | 简短活动摘要 | 按组开合状态显隐。 |
|
||||
| Detailed | 摘要及实时命令、路径、查询详情 | 使用同一组开合状态。 |
|
||||
| Expanded | 隐藏 | 显示,取消组级折叠及限高,容器保留。 |
|
||||
|
||||
以上以外层 Turn 过程已展开为前提。Expanded 保留整轮折叠和单个工具、推理行开合,不能提前挂载全部完整 Markdown。Turn 收起时的内部状态重置是明确业务动作,不通过更换 key 实现。组头、滚动渐隐及整轮联动属于本业务接入。
|
||||
|
||||
| 业务实现 | 职责 |
|
||||
|---|---|
|
||||
| process-groups.ts 与 register.ts | Definition 拥有分段、成员、缓存、摘要及注册。 |
|
||||
| AssistantNodeView、Group 容器、stores、navigation | 解释部分内容,组头和正文展示,本地开合状态,Turn/Group 揭示、选择复制及中断提示归属。 |
|
||||
|
||||
## 性能约束
|
||||
|
||||
| 场景 | 要保留的性质 | 所需证据 |
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^"
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
|
||||
@@ -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 docs/subsystems/conversation.md
|
||||
conversation.md: 4d6324e71b4383dfc640c1873dd9ef31023a1098
|
||||
conversation.zh.md: 49f2d7dbdc67a926f88ca72ed8ae6a0622c54fb3
|
||||
conversation.md: 9455858acac16584b4d300dc8cd2634eb2a20310
|
||||
conversation.zh.md: 2e7c48ab5f65935c2fcb4de05c4484c965622746
|
||||
@@ -16,6 +16,7 @@ The Session Controller owns the contiguous loaded logical-event window. Each `Se
|
||||
| Context | The engine-owned ordered Matches and current State for one `(kind, id)`. A transient event occupies one update Match; update-only evidence may remain pending until pagination supplies its unique durable start. |
|
||||
| Location | The engine-owned Session, Turn, or Step coordinates derived from durable boundary events. Definitions may publish typed data onto one Turn or Step. |
|
||||
| View Definition | A target package creates one incremental builder per Session and owns the final snapshot type for that target. |
|
||||
| Group Definition | A business package derives one target's root references and group snapshots from materialized Nodes; it owns membership, segmentation, summaries, and incremental caches. |
|
||||
| View | A Slot entry such as Chat or Trajectory reads only its target snapshot and renders target-owned nodes. |
|
||||
|
||||
Chat and Trajectory may recognize the same durable event family, but each keeps its own Definition State and final node payload. Shared target-neutral machinery is limited to identity routing, ordered replay, Location data, predecessor dependencies, and publication cadence.
|
||||
@@ -26,6 +27,32 @@ Each Session keeps a monotonic set of active targets. Creating or reading a targ
|
||||
|
||||
The shell owns View selection and resolves the registered preferred View or Chat fallback before rendering when a binding is created or selected as current, and after View-roster changes. The assembler receives only the resolved target id and does not select Chat or another default target. A third-party View participates through the same selection and activation operations. A View Definition may expose `toolCallFocus(callId)`; the shell supplies Inspect only for a visible target that declares this capability, and the target maps the call id to its own focus identity.
|
||||
|
||||
<a id="group-definitions"></a>
|
||||
## Group Definitions
|
||||
|
||||
The optional `ctx.uiConversation.groups.register(definition)` contribution uses the same effect-owned registry lifecycle as event and view registrations. It requires an existing View target and rejects duplicate target registrations without constructing a Builder. Each Session creates its own Group context only when that target activates. Registration replacement discards the old context and clears its observed groups during the existing registry rebuild.
|
||||
|
||||
The [group types](../../packages/client/ui-conversation/src/client/contract/groups.ts) define the complete protocol:
|
||||
|
||||
| Type | Meaning |
|
||||
|---|---|
|
||||
| `ConversationGroupDefinition<Node, State, Data>` | `create()` initializes Session-local State; `update(context, input)` returns its next State; `buildGroups(context)` returns pending output or `null`. Replacement input requires entries and a complete group replacement. |
|
||||
| `ConversationGroupInput<Node>` | `replace` supplies target order, timeline, and synchronous `readNode`, `readTurn`, and `readPosition` readers. `apply` adds projected `previous/current` Node changes, lifecycle `changedTurns`, and `changedTurnOrders`. Do not retain readers in State. |
|
||||
| `GroupNodePosition` | Owning Turn, when present, and immediate previous/next visible Node keys. Neighbours preserve interruptions that a Turn-only key list omits. |
|
||||
| `NodeReference` / `GroupReference` | Branded `NodeKey` or `GroupKey`, distinguished by `kind`. A Node reference may select a renderer-owned `groupPart`; omission selects the whole Node. |
|
||||
| `GroupSnapshot<Data>` | Immutable group key, business data, and ordered Node references. Groups cannot contain other groups or own source Node data. |
|
||||
| `GroupUpdate<Data>` | `entries` replaces the complete root sequence; only apply input may omit it to preserve the sequence. Replacement input requires both `entries` and `groups.replace`. `groups.replace.snapshots` replaces all groups; `groups.apply.upserts/removes` updates only named group records. Removal does not delete Nodes. |
|
||||
| `ConversationGroupDataMap` | Declaration-merged target-to-data association shared by registration and `views.grouped(target)`. Undeclared targets have no group payload type. |
|
||||
| `ConversationGroupedView<Data>` | Stable root `entries` and keyed `groupSource(key)` readers; a removed group reads as `undefined`. |
|
||||
|
||||
The Builder exposes `groupInput()` when grouping is registered; missing input fails at first activation. It records the Node values after its own projections and preserves content-only order identity. The assembler supplies changed Turns with every update; direct ungrouped Builder callers may omit them. Builders with independent sources defer their notifications to `publish()`. First activation and ordinary flush share the same sequence: materialize Location data and Nodes, update the Builder, call the Group Definition, validate and install grouping, then publish every affected target and Location source. The group phase reuses the existing publication cadence.
|
||||
|
||||
Target position indexes supply the readers and identify Turns whose visible keys or neighbours changed. `changedTurnOrders` includes both owners of a moved Node and Turns adjacent to inserted or removed unscoped Nodes; lifecycle-only changes remain in `changedTurns`. A business Definition can resegment those Turns and refresh only groups containing changed content. Structural output still replaces the complete root-reference array, without requiring unchanged Node contents to be reread.
|
||||
|
||||
The Group store validates the whole submitted result before installation: root Group references and records correspond one-to-one, all referenced Nodes exist, and each `(NodeKey, groupPart)` occupies at most one root or member position. A whole Node cannot coexist with one of its parts. Duplicate upserts, duplicate removals, and simultaneous upsert/removal of a group fail. Data-only upserts retain root and member arrays, do not read Nodes, and notify only changed group sources; full replacement revalidates every reference.
|
||||
|
||||
Renderers switch on the two reference kinds and choose components in the owning View, not through a renderer field in group data. Root Node references and referenced group members form the complete render list; unreferenced target Nodes remain stored but do not render. The group body reads members separately from summary data. Presentation modes must retain component types, keys, and member parents; changing data membership may legitimately remount a moved member. The framework does not interpret part completeness, segmentation, or presentation policies.
|
||||
|
||||
## Replayable event families
|
||||
|
||||
Choose one stable business id before writing the Definition. Every event that contributes to the same Node must carry that id or derive it independently from its own payload; the client must never assign an update to “the latest unfinished” Context.
|
||||
|
||||
@@ -16,6 +16,7 @@ Session Controller 拥有连续的已加载逻辑 event window。每个 `Session
|
||||
| Context | Engine 为一个 `(kind, id)` 拥有的有序 Match 与当前 State。一个瞬态 event 只占一个 update Match;只有 update 的证据可以保持 pending,直到分页补齐其唯一持久 start。 |
|
||||
| Location | Engine 根据持久 boundary event 推导的 Session、Turn 或 Step 坐标。Definition 可以向一个 Turn 或 Step 发布类型化数据。 |
|
||||
| View Definition | Target 包为每个 Session 创建一个增量 builder,并拥有该 target 的最终 snapshot 类型。 |
|
||||
| Group Definition | 业务包从物化后的 Node 派生一个目标的根引用和组快照,拥有成员归属、分段、摘要及增量缓存。 |
|
||||
| View | Chat 或 Trajectory 等 Slot entry 只读取自身 target snapshot,并渲染 target 自有 node。 |
|
||||
|
||||
Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留自己的 Definition State 与最终 node payload。共享的 target-neutral 机制只包括 identity routing、有序 replay、Location data、predecessor dependency 与 publication cadence。
|
||||
@@ -26,6 +27,32 @@ Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留
|
||||
|
||||
shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View roster 变化时,于渲染前解析已注册的偏好 View 或 Chat fallback。assembler 只接收解析后的 target id,不自行选择 Chat 或其他默认 target。第三方 View 使用相同的选择与激活操作。View Definition 可以提供 `toolCallFocus(callId)`;shell 仅为声明了此能力且可见的目标提供 Inspect,由目标将调用 id 映射为自己的焦点标识。
|
||||
|
||||
<a id="group-definitions"></a>
|
||||
## Group Definition
|
||||
|
||||
可选的 `ctx.uiConversation.groups.register(definition)` 贡献沿用事件及视图注册的 effect 生命周期。它要求已有 View 目标,拒绝重复目标注册,不构造 Builder。每个 Session 仅在目标激活时创建自己的 Group 上下文。注册替换通过既有注册重建流程丢弃旧上下文,并清空其被观察的组。
|
||||
|
||||
[分组类型](../../packages/client/ui-conversation/src/client/contract/groups.ts)定义完整协议:
|
||||
|
||||
| 类型 | 含义 |
|
||||
|---|---|
|
||||
| `ConversationGroupDefinition<Node, State, Data>` | `create()` 初始化 Session 内的 State;`update(context, input)` 返回下一份 State;`buildGroups(context)` 返回待发布输出或 `null`。替换输入要求输出 entries 与完整组替换。 |
|
||||
| `ConversationGroupInput<Node>` | `replace` 提供目标顺序、时间线及同步的 `readNode`、`readTurn`、`readPosition` 读取器。`apply` 增加投影后的 `previous/current` 节点变化、生命周期 `changedTurns` 和 `changedTurnOrders`。不要把读取器留在 State 中。 |
|
||||
| `GroupNodePosition` | 所属 Turn(如有)及前后紧邻的可见 Node 键。相邻关系保留仅按 Turn 取键时会遗漏的分隔。 |
|
||||
| `NodeReference` / `GroupReference` | 以 `kind` 区分的品牌类型 `NodeKey` 或 `GroupKey`。Node 引用可选择渲染器拥有的 `groupPart`,省略表示整个 Node。 |
|
||||
| `GroupSnapshot<Data>` | 不可变的组键、业务数据和有序 Node 引用。Group 不包含其他 Group,也不拥有原 Node 数据。 |
|
||||
| `GroupUpdate<Data>` | `entries` 替换完整根序列,仅 apply 输入允许省略它以保留原序列。替换输入必须同时提供 `entries` 与 `groups.replace`。`groups.replace.snapshots` 替换全部组;`groups.apply.upserts/removes` 只更新具名组记录。删除组不删除 Node。 |
|
||||
| `ConversationGroupDataMap` | 通过声明合并关联目标与数据类型,注册及 `views.grouped(target)` 共用。未声明的目标没有组载荷类型。 |
|
||||
| `ConversationGroupedView<Data>` | 稳定的根 `entries` 与按键 `groupSource(key)` 读取器;已删除的组返回 `undefined`。 |
|
||||
|
||||
注册分组时 Builder 必须提供 `groupInput()`,缺少输入方法在首次激活时报错。它记录自身投影后的节点值,并在仅正文更新时保留顺序数组身份。assembler 每次更新都提供变化轮次,直接调用未分组 Builder 的调用方可以省略。拥有独立来源的 Builder 把通知推迟到 `publish()`。首次激活与普通 flush 共用同一顺序:物化 Location 数据和 Node,更新 Builder,调用 Group Definition,校验并安装分组,再发布全部受影响目标及 Location 来源。分组阶段复用既有发布节奏。
|
||||
|
||||
目标位置索引提供这些读取器,并标识可见键或相邻关系发生变化的 Turn。`changedTurnOrders` 包含移动 Node 的新旧所属轮次,以及相邻的轮次外 Node 被插入或移除所影响的轮次;仅生命周期变化仍由 `changedTurns` 提供。业务 Definition 可只重分这些轮次,并只刷新包含变化内容的组。结构输出仍替换完整根引用数组,但不要求重读未变化的 Node 内容。
|
||||
|
||||
Group 存储在安装前校验完整提交结果:根 Group 引用与记录一一对应,引用的 Node 全部存在,每个 `(NodeKey, groupPart)` 在根和成员位置中最多占位一次。整 Node 不能与自身任一部分共存。重复 upsert、重复删除以及同时 upsert 和删除一个组都会报错。仅数据 upsert 保留根及成员数组、不读取 Node,并只通知变化组的来源;完整替换重新校验全部引用。
|
||||
|
||||
渲染器按两种引用 kind 切换,由所属 View 选择组件,不通过组数据中的 renderer 字段选择。根 Node 引用与被引用组的成员构成完整渲染列表;未被引用的目标 Node 仍保留在存储中,但不渲染。组 body 将成员与摘要数据分开读取。展示模式必须保留组件类型、key 和成员父级;业务数据改变成员归属时,移动成员可以正常重挂载。框架不解释部分内容完备性、分段或展示策略。
|
||||
|
||||
## 可回放 event family
|
||||
|
||||
编写 Definition 前先选定稳定的业务 id。构成同一个 Node 的每条事件都必须携带该 id,或只凭自身 payload 独立推导出该 id;Client 绝不能把 update 猜测为属于“最近一个未完成”的 Context。
|
||||
|
||||
@@ -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 packages/client/ui-chat/README.md
|
||||
README.md: 2beb4dfadddec0c834c3ef6f4b56826be120baa6
|
||||
README.zh.md: 5d643f4e90b77d2b79ab1bb1c003b399c202e004
|
||||
README.md: 0fd407f33b1a7cba5a1f2612411d9ec333679c9c
|
||||
README.zh.md: e9d5005851f57d632ca4ab7835d32ce14105d3a6
|
||||
@@ -20,6 +20,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
|
||||
- [Turn token usage](#turn-token-usage)
|
||||
- [Completed-turn footer](#completed-turn-footer)
|
||||
- [Turn Process Folding](#turn-process-folding)
|
||||
- [Grouped rendering](#grouped-rendering)
|
||||
- [Scroll ownership](#scroll-ownership)
|
||||
- [Model Experience](#model-experience)
|
||||
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
||||
@@ -85,6 +86,15 @@ Stable Chat Node Seats keep every renderer mounted, hidden members add no flow s
|
||||
|
||||
-----
|
||||
|
||||
<a id="grouped-rendering"></a>
|
||||
## Grouped rendering
|
||||
|
||||
Chat can render optional Conversation Group Definition output as a mixed `node`/`group` root list. A group seat subscribes only to its member array; each member retains the existing keyed Node source and renderer. `groupPart` reaches that renderer as a business-owned part selector, with distinct DOM anchors for reading-position restoration; Turn navigation can still address the original Node key and land on its first visible part. Presentation modes do not select the root branch or alter member parents. No process Group Definition is registered by this package yet; the default transcript remains ungrouped.
|
||||
|
||||
The group seat uses `div` with `display: contents`: it retains the DOM parent without creating a layout box. CSS inheritance remains available, but child and sibling selectors still follow the DOM tree. The existing direct-child spacing selectors do not reach group members. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception, and own any measurable body or scroll container. CSS variables do not belong in the Group Definition.
|
||||
|
||||
-----
|
||||
|
||||
<a id="scroll-ownership"></a>
|
||||
## Scroll ownership
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ kind: "package-reference"
|
||||
- [轮次 token 用量](#turn-token-usage)
|
||||
- [已完成轮次的页脚](#completed-turn-footer)
|
||||
- [轮次过程折叠](#turn-process-folding)
|
||||
- [分组渲染](#grouped-rendering)
|
||||
- [滚动归属](#scroll-ownership)
|
||||
- [模型体验](#model-experience)
|
||||
- [已知限制与暂缓事项](#known-limitations-and-deferred-work)
|
||||
@@ -85,6 +86,15 @@ Chat 在所有工作过程展示模式下都不显示系统提示词行和 `perm
|
||||
|
||||
-----
|
||||
|
||||
<a id="grouped-rendering"></a>
|
||||
## 分组渲染
|
||||
|
||||
Chat 可以把可选的 Conversation Group Definition 输出渲染为混合 `node`/`group` 根列表。组容器只订阅成员数组,每个成员保留既有的按键 Node 来源与渲染器。`groupPart` 作为业务拥有的部分选择器传给渲染器,不同部分具有独立的 DOM 锚点用于恢复阅读位置;轮次导航仍可使用原 Node key,落到它的第一个可见部分。展示模式不选择根分支,也不改变成员父级。本包尚未注册过程 Group Definition,默认文本记录仍未分组。
|
||||
|
||||
组容器使用 `div` 与 `display: contents`,保留 DOM 父级但不产生布局盒子。CSS 继承仍然可用,子级和兄弟选择器仍遵循 DOM 树。原有直接子级间距选择器不会匹配组内成员。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例,并拥有需要测量的正文或滚动容器。CSS 变量不属于 Group Definition。
|
||||
|
||||
-----
|
||||
|
||||
<a id="scroll-ownership"></a>
|
||||
## 滚动归属
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:^",
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { GroupKey } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
||||
@@ -156,11 +157,13 @@ export function apply(ctx: Context): void {
|
||||
if (binding === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`)
|
||||
const session = binding.session
|
||||
const chat = chatSource(binding)
|
||||
const conversation = ctx.uiConversation.binding(binding)
|
||||
return {
|
||||
hooks: { presentation },
|
||||
keyedHooks: {
|
||||
chatNode: key => chat.getSnapshot().nodes.source(key),
|
||||
chatNodeProcess: key => chat.getSnapshot().nodes.processSource(key),
|
||||
chatGroup: key => conversation.snapshot.getSnapshot().views.grouped('chat')?.groupSource(key as GroupKey),
|
||||
},
|
||||
fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId),
|
||||
// Files open in the right Sidebar, not in a desktop application: the
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.root {
|
||||
display: contents;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Stable parent for one group's Node references, independent of display mode. */
|
||||
import { memo, type ComponentProps } from 'react'
|
||||
import type { GroupKey } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { ChatNodeSeat } from './ChatNodeSeat.tsx'
|
||||
import { chatRenderKey } from './render-entry.ts'
|
||||
import css from './ChatGroupSeat.module.css'
|
||||
|
||||
type ChatGroupSeatProps = Omit<ComponentProps<typeof ChatNodeSeat>, 'nodeKey' | 'groupPart'> & {
|
||||
readonly groupKey: GroupKey
|
||||
readonly useChatGroup: ChatViewSlotProps['useChatGroup']
|
||||
}
|
||||
|
||||
/** Render the group's members without moving them between React parents on mode changes. */
|
||||
export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGroup, ...props }: ChatGroupSeatProps) {
|
||||
const members = useChatGroup(groupKey, group => group?.members)
|
||||
if (members === undefined) return null
|
||||
return (
|
||||
<div className={css.root} data-chat-group-key={groupKey}>
|
||||
{members.map(member => (
|
||||
<ChatNodeSeat
|
||||
{...props}
|
||||
key={chatRenderKey(member)}
|
||||
nodeKey={member.key}
|
||||
{...member.groupPart === undefined ? {} : { groupPart: member.groupPart }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ConversationLocationDataStore, ConversationTurnDataMap } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ChatNodeOwnerProps, ChatViewSlotProps, UsePresentation } from '../contract/slots.ts'
|
||||
import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
import type { ChatNodeStore } from '../contract/snapshot.ts'
|
||||
import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
|
||||
import { storedTurnProcessEntry } from '../stores.ts'
|
||||
import { useSearchableHidden } from './searchable-hidden.ts'
|
||||
@@ -10,6 +11,8 @@ import css from './ChatView.module.css'
|
||||
|
||||
interface ChatNodeSeatProps extends ChatNodeOwnerProps {
|
||||
readonly nodeKey: string
|
||||
/** A replaced Builder must rebind keyed hooks even when references and keys survive. */
|
||||
readonly nodeStore: ChatNodeStore
|
||||
readonly useChatNode: ChatViewSlotProps['useChatNode']
|
||||
readonly useChatNodeProcess: ChatViewSlotProps['useChatNodeProcess']
|
||||
readonly usePresentation: UsePresentation
|
||||
@@ -39,7 +42,7 @@ function turnOf(node: ChatNode | undefined): number | undefined {
|
||||
* only seats whose visibility actually changes.
|
||||
*/
|
||||
export const ChatNodeSeat = memo(function ChatNodeSeat({
|
||||
nodeKey, useChatNode, useChatNodeProcess, usePresentation,
|
||||
nodeKey, groupPart, useChatNode, useChatNodeProcess, usePresentation,
|
||||
cwd, openFile, openSkill, inspectCall, forkAt,
|
||||
loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, t,
|
||||
}: ChatNodeSeatProps) {
|
||||
@@ -108,6 +111,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
|
||||
const owner = useMemo<ChatNodeOwnerProps | null>(() => node === undefined
|
||||
? null
|
||||
: {
|
||||
...groupPart === undefined ? {} : { groupPart },
|
||||
cwd,
|
||||
openFile,
|
||||
openSkill,
|
||||
@@ -118,7 +122,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
|
||||
fileMentions,
|
||||
turnProcess,
|
||||
}, [
|
||||
node, cwd, openFile, openSkill, inspectCall, forkAt,
|
||||
node, groupPart, cwd, openFile, openSkill, inspectCall, forkAt,
|
||||
loadImage, renderMessageImages, fileMentions, turnProcess,
|
||||
])
|
||||
if (routedNode === undefined || owner === null) return null
|
||||
@@ -127,12 +131,15 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
|
||||
// keyed-slot entry passed alongside that same Node. TypeScript does not
|
||||
// distribute an object containing a union into a union of objects itself.
|
||||
const routedOwner = { ...owner, node: routedNode } as RoutedChatNodeOwner
|
||||
const flowKey = groupPart === undefined ? routedNode.key : JSON.stringify([routedNode.key, groupPart])
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={css.flowItem}
|
||||
data-chat-anchor-key={routedNode.key}
|
||||
data-chat-flow-key={routedNode.key}
|
||||
data-chat-anchor-key={flowKey}
|
||||
data-chat-flow-key={flowKey}
|
||||
data-chat-node-key={routedNode.key}
|
||||
data-chat-group-part={groupPart}
|
||||
data-chat-flow-kind={routedNode.kind}
|
||||
data-chat-turn={turn}
|
||||
data-turn-process-member={processMember || undefined}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from 'react'
|
||||
import type {
|
||||
ConversationTimelineSnapshot, RenderMessageImages,
|
||||
ConversationTimelineSnapshot, NodeKey, RenderEntry, RenderMessageImages,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
|
||||
import {
|
||||
@@ -13,6 +13,9 @@ import type { ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts'
|
||||
import type { ChatSnapshot } from '../contract/snapshot.ts'
|
||||
import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx'
|
||||
import { ChatNodeSeat } from './ChatNodeSeat.tsx'
|
||||
import { ChatGroupSeat } from './ChatGroupSeat.tsx'
|
||||
import { chatRenderKey } from './render-entry.ts'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import { TurnNavigator } from './TurnNavigator.tsx'
|
||||
import { mergeTurnRailItems } from './turn-rail-items.ts'
|
||||
import { useChatScroll } from './use-chat-scroll.ts'
|
||||
@@ -96,14 +99,23 @@ function TurnStatus({ startTime, t }: {
|
||||
)
|
||||
}
|
||||
|
||||
type ChatNodeListProps = Omit<ComponentProps<typeof ChatNodeSeat>, 'nodeKey'> & {
|
||||
readonly order: readonly string[]
|
||||
type ChatNodeListProps = Omit<ComponentProps<typeof ChatNodeSeat>, 'nodeKey' | 'groupPart'> & {
|
||||
readonly entries: readonly RenderEntry[]
|
||||
readonly useChatGroup: ChatViewSlotProps['useChatGroup']
|
||||
}
|
||||
|
||||
const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNodeListProps) {
|
||||
return order.map(nodeKey => (
|
||||
<ChatNodeSeat key={nodeKey} nodeKey={nodeKey} {...seatProps} />
|
||||
))
|
||||
const ChatNodeList = memo(function ChatNodeList({ entries, useChatGroup, ...seatProps }: ChatNodeListProps) {
|
||||
return entries.map((entry) => {
|
||||
switch (entry.kind) {
|
||||
case 'node':
|
||||
return <ChatNodeSeat {...seatProps} key={chatRenderKey(entry)} nodeKey={entry.key}
|
||||
{...entry.groupPart === undefined ? {} : { groupPart: entry.groupPart }} />
|
||||
case 'group':
|
||||
return <ChatGroupSeat {...seatProps} key={chatRenderKey(entry)} groupKey={entry.key} useChatGroup={useChatGroup} />
|
||||
default:
|
||||
return assertNever(entry)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -111,11 +123,14 @@ const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNod
|
||||
* ordered business Node crosses the keyed renderer seat.
|
||||
*/
|
||||
export function ChatView({
|
||||
useSession, useChat, useChatNode, useChatNodeProcess, useSessions, useStore, actions, renderSlot,
|
||||
useSession, useChat, useChatNode, useChatNodeProcess, useChatGroup, useConversation, useSessions, useStore, actions, renderSlot,
|
||||
sessionId, openFile, openSkill, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions,
|
||||
usePresentation, useProjection, t,
|
||||
}: ChatViewSlotProps) {
|
||||
const order = useChat(s => s.order)
|
||||
const groupedEntries = useConversation(snapshot => snapshot.views.grouped('chat')?.entries)
|
||||
const entries = useMemo<readonly RenderEntry[]>(() => groupedEntries
|
||||
?? order.map(key => ({ kind: 'node', key: key as NodeKey })), [groupedEntries, order])
|
||||
const nodeStore = useChat(s => s.nodes)
|
||||
// The rail's items are accumulated in the Chat snapshot, so this selector is
|
||||
// both the data and its change signal: the array identity moves only when a
|
||||
@@ -233,7 +248,9 @@ export function ChatView({
|
||||
)}
|
||||
<MarkdownDelegateProvider openExternalLink={openExternalLink} openFile={requestOpenFile}>
|
||||
<ChatNodeList
|
||||
order={order}
|
||||
entries={entries}
|
||||
nodeStore={nodeStore}
|
||||
useChatGroup={useChatGroup}
|
||||
useChatNode={useChatNode}
|
||||
useChatNodeProcess={useChatNodeProcess}
|
||||
usePresentation={usePresentation}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Stable React identities for the two grouping reference kinds. */
|
||||
import type { RenderEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
|
||||
/**
|
||||
* Identify a rendering position independently of presentation mode.
|
||||
* @param entry - mode-independent rendering reference.
|
||||
* @returns its collision-free React key.
|
||||
*/
|
||||
export function chatRenderKey(entry: RenderEntry): string {
|
||||
switch (entry.kind) {
|
||||
case 'node': return JSON.stringify(['node', entry.key, entry.groupPart ?? null])
|
||||
case 'group': return JSON.stringify(['group', entry.key])
|
||||
default: return assertNever(entry)
|
||||
}
|
||||
}
|
||||
@@ -141,10 +141,13 @@ export class ChatViewport {
|
||||
|
||||
private anchor(key: string): HTMLElement | null {
|
||||
if (this.elements === null) return null
|
||||
for (const row of this.elements.list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]:not([hidden])')) {
|
||||
// Reading anchors name exact parts; Turn navigation names the original Node.
|
||||
let nodePart: HTMLElement | null = null
|
||||
for (const row of this.elements.list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]:not([hidden]):not([hidden] *)')) {
|
||||
if (row.dataset.chatAnchorKey === key) return row
|
||||
if (nodePart === null && row.dataset.chatNodeKey === key) nodePart = row
|
||||
}
|
||||
return null
|
||||
return nodePart
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,7 +172,8 @@ export class ChatViewport {
|
||||
}
|
||||
if (anchor === null) {
|
||||
const rows = list.querySelectorAll<HTMLElement>(
|
||||
'[data-chat-flow] > [data-chat-flow-key]:not(:empty):not([hidden])',
|
||||
':is([data-chat-flow], [data-chat-flow] > [data-chat-group-key])'
|
||||
+ ' > [data-chat-flow-key]:not([data-chat-group-key]):not(:empty):not([hidden]):not([hidden] *)',
|
||||
)
|
||||
let low = 0
|
||||
let high = rows.length
|
||||
@@ -229,6 +233,7 @@ export class ChatViewport {
|
||||
|
||||
/**
|
||||
* Align a known loaded turn and return its actual clamped position.
|
||||
* A split Node anchor selects its first visible part.
|
||||
* @param turn - loaded turn to align below the scrollport's top edge.
|
||||
* @returns the actual landing, or null when its anchor is unavailable.
|
||||
*/
|
||||
@@ -246,7 +251,7 @@ export class ChatViewport {
|
||||
*/
|
||||
scrollToTurnAtOrAfter(turn: number): ViewportLanding | null {
|
||||
if (this.elements === null) return null
|
||||
for (const row of this.elements.list.querySelectorAll<HTMLElement>('[data-chat-turn]:not([hidden])')) {
|
||||
for (const row of this.elements.list.querySelectorAll<HTMLElement>('[data-chat-turn]:not([hidden]):not([hidden] *)')) {
|
||||
const candidate = Number(row.dataset.chatTurn)
|
||||
if (Number.isSafeInteger(candidate) && candidate >= turn) return this.align(row, 24, candidate)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
CommandNode, CompactionSummaryNode, ConversationLocationDataStore, ConversationTurnDataMap,
|
||||
ConversationGroupData, GroupSnapshot,
|
||||
MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
@@ -86,6 +87,8 @@ export interface ChatNodeTurnDataInjected {
|
||||
|
||||
/** Stable owner currency delivered to a keyed Chat renderer. */
|
||||
export interface ChatNodeOwnerProps {
|
||||
/** Renderer-owned Node portion selected by the grouping Definition. */
|
||||
groupPart?: string
|
||||
cwd?: string | undefined
|
||||
/** Open the current source file of a skill referenced by a sent message. */
|
||||
openSkill: (name: string) => void
|
||||
@@ -163,6 +166,8 @@ export interface ChatViewInjected {
|
||||
chatNode: (key: string) => ChatNodeSource
|
||||
/** Resolve the stable Turn-process source for one Chat Node key. */
|
||||
chatNodeProcess: (key: string) => ChatNodeProcessSource
|
||||
/** Resolve one optional group without subscribing the root View to its data. */
|
||||
chatGroup: (key: string) => ObservableSnapshot<GroupSnapshot<ConversationGroupData<'chat'>> | undefined> | undefined
|
||||
}
|
||||
/** Open the current source file of a skill referenced by a sent message. */
|
||||
openSkill: (name: string) => void
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
|
||||
import type {
|
||||
ConversationLocation, ConversationNode, ConversationTimelineSnapshot, ConversationViewBuilder,
|
||||
ConversationViewDefinition, PartialAssistant, RunningToolCall,
|
||||
ConversationViewDefinition, ConversationGroupInput, GroupNodePosition, NodeChange, NodeKey, PartialAssistant, RunningToolCall,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts'
|
||||
import { isRunningTool } from '../contract/chat-nodes.ts'
|
||||
@@ -164,6 +164,7 @@ class MutableChatNodeStore implements ChatNodeStore {
|
||||
class MutableChatLocationIndex implements ChatLocationNodeIndex {
|
||||
private turns = new Map<number, readonly string[]>()
|
||||
private steps = new Map<string, readonly string[]>()
|
||||
private positions = new Map<string, GroupNodePosition>()
|
||||
|
||||
getTurn(turn: number): readonly string[] {
|
||||
return this.turns.get(turn) ?? EMPTY_KEYS
|
||||
@@ -173,13 +174,32 @@ class MutableChatLocationIndex implements ChatLocationNodeIndex {
|
||||
return this.steps.get(stepKey(turn, step)) ?? EMPTY_KEYS
|
||||
}
|
||||
|
||||
rebuild(order: readonly string[], store: ChatNodeStore): void {
|
||||
getPosition(key: string): GroupNodePosition | undefined {
|
||||
return this.positions.get(key)
|
||||
}
|
||||
|
||||
rebuild(order: readonly string[], store: ChatNodeStore): readonly number[] {
|
||||
const turns = new Map<number, string[]>()
|
||||
const steps = new Map<string, string[]>()
|
||||
for (const key of order) {
|
||||
const positions = new Map<string, GroupNodePosition>()
|
||||
const changedTurns = new Set<number>()
|
||||
for (const [index, key] of order.entries()) {
|
||||
const location = store.get(key)?.location
|
||||
if (location === undefined) continue
|
||||
const coordinates = locationCoordinates(location)
|
||||
const previous = this.positions.get(key)
|
||||
const position: GroupNodePosition = {
|
||||
turn: coordinates.turn,
|
||||
previous: order[index - 1] as NodeKey | undefined,
|
||||
next: order[index + 1] as NodeKey | undefined,
|
||||
}
|
||||
const unchanged = previous !== undefined && previous.turn === position.turn
|
||||
&& previous.previous === position.previous && previous.next === position.next
|
||||
positions.set(key, unchanged ? previous : position)
|
||||
if (!unchanged) {
|
||||
if (previous?.turn !== undefined) changedTurns.add(previous.turn)
|
||||
if (position.turn !== undefined) changedTurns.add(position.turn)
|
||||
}
|
||||
if (coordinates.turn === undefined) continue
|
||||
const turnKeys = turns.get(coordinates.turn) ?? []
|
||||
turnKeys.push(key)
|
||||
@@ -190,8 +210,13 @@ class MutableChatLocationIndex implements ChatLocationNodeIndex {
|
||||
stepKeys.push(key)
|
||||
steps.set(step, stepKeys)
|
||||
}
|
||||
for (const [key, position] of this.positions) {
|
||||
if (!positions.has(key) && position.turn !== undefined) changedTurns.add(position.turn)
|
||||
}
|
||||
this.positions = positions
|
||||
this.turns = updateIndex(this.turns, turns)
|
||||
this.steps = updateIndex(this.steps, steps)
|
||||
return [...changedTurns]
|
||||
}
|
||||
|
||||
/** Invalidate aggregate readers when member data changes without moving. */
|
||||
@@ -966,12 +991,24 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
|
||||
private readonly referenceLabels = new ReferenceLabelProjector()
|
||||
private readonly skillNames = new SkillNameProjector()
|
||||
private order: readonly string[] = EMPTY_KEYS
|
||||
private latestGroupInput: ConversationGroupInput<ChatConversationViewNode>
|
||||
private readonly readGroupNode = (key: NodeKey): ChatConversationViewNode | undefined => this.store.get(key)
|
||||
private readonly readGroupTurn = (turn: number): readonly NodeKey[] => this.locations.getTurn(turn) as readonly NodeKey[]
|
||||
private readonly readGroupPosition = (key: NodeKey): GroupNodePosition | undefined => this.locations.getPosition(key)
|
||||
/** Last published timeline: a Turn boundary can land without a new node. */
|
||||
private timeline: ConversationTimelineSnapshot | null = null
|
||||
readonly empty: ChatSnapshot
|
||||
|
||||
constructor() {
|
||||
this.empty = this.snapshot({ turnOrder: EMPTY_TURNS, turns: new Map() })
|
||||
this.latestGroupInput = {
|
||||
kind: 'replace',
|
||||
order: this.order as readonly NodeKey[],
|
||||
readNode: this.readGroupNode,
|
||||
readTurn: this.readGroupTurn,
|
||||
readPosition: this.readGroupPosition,
|
||||
timeline: this.empty.timeline,
|
||||
}
|
||||
}
|
||||
|
||||
replace(input: {
|
||||
@@ -985,21 +1022,31 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
|
||||
this.store.replaceProcesses(this.order, this.locations)
|
||||
this.navigation.rebuild(input.timeline, this.locations, this.store)
|
||||
this.timeline = input.timeline
|
||||
this.latestGroupInput = {
|
||||
kind: 'replace',
|
||||
order: this.order as readonly NodeKey[],
|
||||
readNode: this.readGroupNode,
|
||||
readTurn: this.readGroupTurn,
|
||||
readPosition: this.readGroupPosition,
|
||||
timeline: input.timeline,
|
||||
}
|
||||
const snapshot = this.snapshot(input.timeline, this.legacy.replace(nodes, input.timeline))
|
||||
this.store.publish()
|
||||
return snapshot
|
||||
}
|
||||
|
||||
apply(input: {
|
||||
readonly upserts: readonly ChatConversationViewNode[]
|
||||
readonly timeline: ConversationTimelineSnapshot
|
||||
readonly changedTurns?: readonly number[]
|
||||
}): ChatSnapshot {
|
||||
const upserts = this.skillNames.apply(this.referenceLabels.apply(input.upserts, this.store), this.store)
|
||||
const processTurns = new Set<number>()
|
||||
let structural = false
|
||||
const contentOnly: ChatConversationViewNode[] = []
|
||||
const changes: NodeChange<ChatConversationViewNode>[] = []
|
||||
for (const node of upserts) {
|
||||
const previous = this.store.get(node.key)
|
||||
if (previous !== node) changes.push({ previous, current: node })
|
||||
const nodeStructural = previous === undefined
|
||||
|| previous.kind !== node.kind
|
||||
|| previous.anchorSeq !== node.anchorSeq
|
||||
@@ -1015,10 +1062,11 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
|
||||
}
|
||||
}
|
||||
this.store.upsert(upserts)
|
||||
let changedTurnOrders: readonly number[] = EMPTY_TURNS
|
||||
if (structural) {
|
||||
const next = orderedVisibleChatNodes(this.store.values()).map(node => node.key)
|
||||
this.order = sameReferences(this.order, next) ? this.order : next
|
||||
this.locations.rebuild(this.order, this.store)
|
||||
changedTurnOrders = this.locations.rebuild(this.order, this.store)
|
||||
}
|
||||
this.locations.touch(contentOnly)
|
||||
this.store.updateProcesses(processTurns, this.locations)
|
||||
@@ -1028,11 +1076,29 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
|
||||
this.navigation.touch(turnsOf(contentOnly), this.locations, this.store)
|
||||
}
|
||||
this.timeline = input.timeline
|
||||
this.latestGroupInput = {
|
||||
kind: 'apply',
|
||||
changes,
|
||||
order: this.order as readonly NodeKey[],
|
||||
readNode: this.readGroupNode,
|
||||
readTurn: this.readGroupTurn,
|
||||
readPosition: this.readGroupPosition,
|
||||
timeline: input.timeline,
|
||||
changedTurns: input.changedTurns ?? EMPTY_TURNS,
|
||||
changedTurnOrders,
|
||||
}
|
||||
const snapshot = this.snapshot(input.timeline, this.legacy.apply(upserts, input.timeline))
|
||||
this.store.publish()
|
||||
return snapshot
|
||||
}
|
||||
|
||||
groupInput(): ConversationGroupInput<ChatConversationViewNode> {
|
||||
return this.latestGroupInput
|
||||
}
|
||||
|
||||
publish(): void {
|
||||
this.store.publish()
|
||||
}
|
||||
|
||||
private snapshot(
|
||||
timeline: ConversationTimelineSnapshot,
|
||||
legacy = this.legacy.replace(EMPTY_LIST, timeline),
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runt
|
||||
import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import {
|
||||
apply as applyConversation, inject as injectConversation,
|
||||
type GroupKey,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import {
|
||||
apply as applyChat, inject as injectChat, type ChatViewInjected,
|
||||
@@ -22,6 +23,12 @@ import { createChatStore } from '../src/client/stores.ts'
|
||||
import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts'
|
||||
import type { LinkOpeningRowInjected } from '../src/client/settings/LinkOpeningRow.tsx'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ConversationGroupDataMap {
|
||||
chat: number
|
||||
}
|
||||
}
|
||||
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
const ROOT = 'root-1' as SessionId
|
||||
@@ -127,6 +134,36 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true)
|
||||
}
|
||||
|
||||
describe('Chat inject API', () => {
|
||||
it('resolves keyed Group sources across registration, activation, and removal', async () => {
|
||||
const b = await bench()
|
||||
try {
|
||||
const { injected } = b.chatViewApi(b.rootReference)
|
||||
const key = 'injected-group' as GroupKey
|
||||
expect(injected.keyedHooks.chatGroup(key)).toBeUndefined()
|
||||
const conversation = b.runtime.ctx.uiConversation
|
||||
const remove = conversation.groups.register({
|
||||
kind: 'test-group', target: 'chat',
|
||||
create: () => null,
|
||||
update: () => null,
|
||||
buildGroups: () => ({
|
||||
entries: [{ kind: 'group', key }],
|
||||
groups: { kind: 'replace', snapshots: [{ key, data: 1, members: [] }] },
|
||||
}),
|
||||
})
|
||||
await Promise.resolve()
|
||||
conversation.binding(b.rootReference.binding).activate('chat')
|
||||
const source = injected.keyedHooks.chatGroup(key)
|
||||
expect(source?.getSnapshot()?.data).toBe(1)
|
||||
expect(injected.keyedHooks.chatGroup(key)).toBe(source)
|
||||
remove()
|
||||
await Promise.resolve()
|
||||
expect(source?.getSnapshot()).toBeUndefined()
|
||||
expect(injected.keyedHooks.chatGroup(key)).toBeUndefined()
|
||||
} finally {
|
||||
await b.runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('loads older history and forks through the Session Controller', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.chatViewApi(b.rootReference)
|
||||
|
||||
@@ -28,6 +28,44 @@ function userNode(index: number, text = `message ${String(index)}`): ChatConvers
|
||||
}
|
||||
|
||||
describe('Chat Node keyed sources', () => {
|
||||
it('provides ordered projected Nodes and previous values to grouping', () => {
|
||||
const builder = new ChatSnapshotBuilder()
|
||||
const user = userNode(1)
|
||||
const snapshot = builder.replace({ nodes: [user], timeline })
|
||||
const initial = builder.groupInput()
|
||||
expect(initial.kind).toBe('replace')
|
||||
expect(initial.order).toBe(snapshot.order)
|
||||
expect(initial.readNode(initial.order[0]!)).toBe(user)
|
||||
expect(initial.readPosition(initial.order[0]!)).toEqual({ turn: undefined, previous: undefined, next: undefined })
|
||||
expect(initial.readTurn(7)).toEqual([])
|
||||
const context: ChatConversationViewNode = {
|
||||
key: 'context:2', kind: 'context', id: '2', target: 'chat', anchorSeq: 2,
|
||||
location: { kind: 'session' }, visibility: 'visible',
|
||||
data: {
|
||||
kind: 'context', seq: 2, time: 2, content: [],
|
||||
source: { kind: 'skill-invocation', name: 'demo', form: 'instructions' },
|
||||
},
|
||||
}
|
||||
const next = builder.apply({ upserts: [context], timeline, changedTurns: [7] })
|
||||
const input = builder.groupInput()
|
||||
if (input.kind !== 'apply') throw new Error('expected apply')
|
||||
expect(input.order).toBe(next.order)
|
||||
expect(input.changedTurns).toEqual([7])
|
||||
expect(input.changedTurnOrders).toEqual([])
|
||||
expect(input.readPosition(context.key as typeof input.order[number])).toEqual({
|
||||
turn: undefined, previous: user.key, next: undefined,
|
||||
})
|
||||
expect(input.readPosition('missing' as typeof input.order[number])).toBeUndefined()
|
||||
expect(input.changes.find(change => change.current.key === user.key)).toEqual({
|
||||
previous: user, current: next.nodes.get(user.key),
|
||||
})
|
||||
expect(next.nodes.get(user.key)?.data).toMatchObject({ skillNames: ['demo'] })
|
||||
expect(input.changes.find(change => change.current.key === context.key)).toEqual({ previous: undefined, current: context })
|
||||
builder.apply({ upserts: [], timeline })
|
||||
expect(builder.groupInput()).toMatchObject({ kind: 'apply', changes: [], changedTurns: [], changedTurnOrders: [] })
|
||||
expect(builder.groupInput().order).toBe(next.order)
|
||||
})
|
||||
|
||||
it('notifies only the updated key among 4,000 mounted sources', () => {
|
||||
const builder = new ChatSnapshotBuilder()
|
||||
const nodes = Array.from({ length: 4_000 }, (_, index) => userNode(index + 1))
|
||||
@@ -43,6 +81,9 @@ describe('Chat Node keyed sources', () => {
|
||||
const changed = userNode(target + 1, 'streamed update')
|
||||
const next = builder.apply({ upserts: [changed], timeline })
|
||||
|
||||
expect(listeners[target]).not.toHaveBeenCalled()
|
||||
builder.publish()
|
||||
|
||||
expect(listeners[target]).toHaveBeenCalledOnce()
|
||||
expect(listeners.reduce((count, listener) => count + listener.mock.calls.length, 0)).toBe(1)
|
||||
expect(next.nodes.source(changed.key)).toBe(sources[target])
|
||||
|
||||
@@ -16,7 +16,8 @@ import type {
|
||||
SessionListState, SessionSnapshot,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type {
|
||||
ConversationLocationDataStore, ConversationTurnDataMap,
|
||||
ConversationLocationDataStore, ConversationTurnDataMap, ConversationGroupedView,
|
||||
ConversationSnapshot, ConversationViewSnapshotStore, GroupKey, GroupSnapshot, NodeKey,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -47,6 +48,13 @@ import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snaps
|
||||
import type { TurnProcessSpec } from '../src/client/contract/turn-process.ts'
|
||||
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
|
||||
import { installTurnNavigatorObserver } from './turn-navigator-fixture.ts'
|
||||
import { ConversationGroupStore } from '../../ui-conversation/src/client/conversation/group-store.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ConversationGroupDataMap {
|
||||
chat: number
|
||||
}
|
||||
}
|
||||
|
||||
// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
|
||||
const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
|
||||
@@ -259,6 +267,18 @@ function makeHarness(
|
||||
const useChatNodeProcess = bindKeyedSnapshotSelector(
|
||||
key => chatSource.source.getSnapshot().nodes.processSource(key),
|
||||
)
|
||||
let grouped: ConversationGroupedView<number> | undefined
|
||||
const absentGroup = createSnapshotStore<GroupSnapshot<number> | undefined>(undefined)
|
||||
const conversation = createSnapshotStore<ConversationSnapshot>({
|
||||
...EMPTY_CONVERSATION_SNAPSHOT,
|
||||
views: {
|
||||
...EMPTY_CONVERSATION_SNAPSHOT.views,
|
||||
grouped: (() => grouped) as ConversationViewSnapshotStore['grouped'],
|
||||
},
|
||||
})
|
||||
const useChatGroup = bindKeyedSnapshotSelector(
|
||||
key => grouped?.groupSource(key as GroupKey) ?? absentGroup,
|
||||
)
|
||||
const openFile = vi.fn<(path: string) => Promise<void>>().mockResolvedValue(undefined)
|
||||
const openSkill = vi.fn<(name: string) => void>()
|
||||
const loadOlder = vi.fn()
|
||||
@@ -380,7 +400,8 @@ function makeHarness(
|
||||
useChat: bindSnapshotSelector(chatSource.source),
|
||||
useChatNode,
|
||||
useChatNodeProcess,
|
||||
useConversation: bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT)),
|
||||
useChatGroup,
|
||||
useConversation: bindSnapshotSelector(conversation),
|
||||
useTrajectory: (() => { throw new Error('unused') }),
|
||||
useSessions: emptySessions(),
|
||||
useSessionRetainInfo: () => undefined,
|
||||
@@ -448,6 +469,10 @@ function makeHarness(
|
||||
setOutline: (value: unknown) => { outlineValue = value },
|
||||
chatScroll, forkAt, toolOwners,
|
||||
setPerformanceUsage: (mode: 'compact' | 'detailed') => { performanceUsage.set(mode) },
|
||||
setGrouped: (value: ConversationGroupedView<number> | undefined) => {
|
||||
grouped = value
|
||||
conversation.set({ ...conversation.getSnapshot() })
|
||||
},
|
||||
setTranscriptView: (mode: TranscriptViewMode) => { transcriptView.set(mode) },
|
||||
setNodeRenderer: (renderer: React.ComponentProps<typeof ChatNodeSeat>['renderSlot']) => {
|
||||
nodeSlotOverride = renderer
|
||||
@@ -484,10 +509,12 @@ function withSystemPrompt(
|
||||
visibility: 'visible',
|
||||
data: { text },
|
||||
}
|
||||
return builder.replace({
|
||||
const next = builder.replace({
|
||||
nodes: [prompt, ...snapshot.nodes.values()],
|
||||
timeline: snapshot.timeline,
|
||||
})
|
||||
builder.publish()
|
||||
return next
|
||||
}
|
||||
|
||||
function renderedFlowKinds(container: HTMLElement): Array<string | undefined> {
|
||||
@@ -582,6 +609,125 @@ describe('Chat node rendering', () => {
|
||||
})
|
||||
|
||||
describe('ChatView', () => {
|
||||
it('renders only referenced Nodes without deleting unreferenced Node data', () => {
|
||||
const snapshot = chatSnapshotFixture({ nodes: [user(1, 'included'), user(2, 'omitted')] })
|
||||
const h = makeHarness({}, {}, snapshot)
|
||||
const [included, omitted] = snapshot.order
|
||||
if (included === undefined || omitted === undefined) throw new Error('expected two Nodes')
|
||||
const groupStore = new ConversationGroupStore<number>()
|
||||
groupStore.prepareAndInstall({
|
||||
entries: [{ kind: 'node', key: included as NodeKey }],
|
||||
groups: { kind: 'replace', snapshots: [] },
|
||||
}, id => snapshot.nodes.get(id))
|
||||
h.setGrouped(groupStore)
|
||||
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByText('included')).toBeTruthy()
|
||||
expect(view.queryByText('omitted')).toBeNull()
|
||||
expect(snapshot.nodes.get(omitted)).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps grouped Node instances mounted across presentation modes and group data updates', () => {
|
||||
const snapshot = chatSnapshotFixture({ nodes: [user(1, 'outside'), user(2, 'inside')] })
|
||||
const h = makeHarness({}, {}, snapshot)
|
||||
const [outside, inside] = snapshot.order.map(key => ({ kind: 'node' as const, key: key as NodeKey }))
|
||||
if (outside === undefined || inside === undefined) throw new Error('expected two Nodes')
|
||||
const key = 'process' as GroupKey
|
||||
const groupStore = new ConversationGroupStore<number>()
|
||||
const record = { key, data: 0, members: [inside] }
|
||||
groupStore.prepareAndInstall({
|
||||
entries: [outside, { kind: 'group', key }],
|
||||
groups: { kind: 'replace', snapshots: [record] },
|
||||
}, id => snapshot.nodes.get(id))
|
||||
groupStore.publish()
|
||||
h.setGrouped(groupStore)
|
||||
h.setNodeRenderer(((slot: string, owner: object) => {
|
||||
if (slot !== 'conversation.chat.node' || !('node' in owner)) return null
|
||||
const node = (owner as RoutedChatNodeOwner).node
|
||||
return <input aria-label={node.key} defaultValue={node.kind} />
|
||||
}) as ChatViewSlotProps['renderSlot'])
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const input = view.getByRole('textbox', { name: inside.key }) as HTMLInputElement
|
||||
const parent = input.closest('[data-chat-group-key]')
|
||||
expect(parent).not.toBeNull()
|
||||
expect(parent?.tagName).toBe('DIV')
|
||||
fireEvent.change(input, { target: { value: 'retained local input' } })
|
||||
for (const mode of ['detailed', 'expanded', 'compact'] as const) {
|
||||
act(() => { h.setTranscriptView(mode) })
|
||||
expect(view.getByRole('textbox', { name: inside.key })).toBe(input)
|
||||
expect(input.value).toBe('retained local input')
|
||||
expect(input.closest('[data-chat-group-key]')).toBe(parent)
|
||||
}
|
||||
act(() => {
|
||||
groupStore.prepareAndInstall({ groups: { kind: 'apply', upserts: [{ ...record, data: 1 }], removes: [] } }, id => snapshot.nodes.get(id))
|
||||
groupStore.publish()
|
||||
})
|
||||
expect(view.getByRole('textbox', { name: inside.key })).toBe(input)
|
||||
expect(view.getByRole('textbox', { name: outside.key }).closest('[data-chat-group-key]')).toBeNull()
|
||||
act(() => {
|
||||
groupStore.clear()
|
||||
groupStore.publish()
|
||||
})
|
||||
expect(view.queryByRole('textbox', { name: inside.key })).toBeNull()
|
||||
act(() => { h.setGrouped(groupStore) })
|
||||
expect(view.queryByRole('textbox', { name: outside.key })).toBeNull()
|
||||
})
|
||||
|
||||
it('passes independently keyed group parts to business Node renderers', () => {
|
||||
const snapshot = chatSnapshotFixture({ nodes: [assistant(1, 'answer')] })
|
||||
const h = makeHarness({}, {}, snapshot)
|
||||
const nodeKey = snapshot.order.find(key => snapshot.nodes.get(key)?.kind === 'assistant-step') as NodeKey
|
||||
const key = 'parts' as GroupKey
|
||||
const groupStore = new ConversationGroupStore<number>()
|
||||
groupStore.prepareAndInstall({
|
||||
entries: [{ kind: 'group', key }, { kind: 'node', key: nodeKey, groupPart: 'response' }],
|
||||
groups: { kind: 'replace', snapshots: [{ key, data: 0, members: [{ kind: 'node', key: nodeKey, groupPart: 'reasoning' }] }] },
|
||||
}, id => snapshot.nodes.get(id))
|
||||
h.setGrouped(groupStore)
|
||||
h.setNodeRenderer(((slot: string, owner: object) => {
|
||||
if (slot !== 'conversation.chat.node' || !('node' in owner)) return null
|
||||
return <span>{(owner as RoutedChatNodeOwner).groupPart}</span>
|
||||
}) as ChatViewSlotProps['renderSlot'])
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByText('reasoning').closest('[data-chat-group-key]')).not.toBeNull()
|
||||
expect(view.getByText('response').closest('[data-chat-group-key]')).toBeNull()
|
||||
const anchors = [...view.container.querySelectorAll('[data-chat-group-part]')]
|
||||
.map(element => element.getAttribute('data-chat-flow-key'))
|
||||
expect(new Set(anchors).size).toBe(2)
|
||||
expect([...view.container.querySelectorAll('[data-chat-group-part]')]
|
||||
.map(element => element.getAttribute('data-chat-node-key'))).toEqual([nodeKey, nodeKey])
|
||||
})
|
||||
|
||||
it('rebinds grouped members when registry rebuilding replaces the Node store', () => {
|
||||
const snapshot = chatSnapshotFixture({ nodes: [user(1, 'before rebuild')] })
|
||||
const h = makeHarness({}, {}, snapshot)
|
||||
const key = 'retained-group' as GroupKey
|
||||
const members = snapshot.order.map(id => ({ kind: 'node' as const, key: id as NodeKey }))
|
||||
const groups = new ConversationGroupStore<number>()
|
||||
groups.prepareAndInstall({
|
||||
entries: [{ kind: 'group', key }],
|
||||
groups: { kind: 'replace', snapshots: [{ key, data: 0, members }] },
|
||||
}, id => snapshot.nodes.get(id))
|
||||
groups.publish()
|
||||
h.setGrouped(groups)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const original = view.getByText('before rebuild').closest('[data-chat-anchor-key]')
|
||||
const builder = new ChatSnapshotBuilder()
|
||||
const replacement = builder.replace({ nodes: snapshot.nodes.values(), timeline: snapshot.timeline })
|
||||
act(() => { h.set({ chat: replacement }) })
|
||||
const current = replacement.nodes.get(members[0]!.key) as ChatNode<'user'>
|
||||
act(() => {
|
||||
builder.apply({
|
||||
upserts: [{ ...current, data: { ...current.data, content: [{ type: 'text', text: 'after rebuild' }] } }],
|
||||
timeline: snapshot.timeline,
|
||||
})
|
||||
builder.publish()
|
||||
})
|
||||
expect(view.queryByText('before rebuild')).toBeNull()
|
||||
expect(view.getByText('after rebuild').closest('[data-chat-anchor-key]')).toBe(original)
|
||||
expect(groups.groupSource(key).getSnapshot()?.members).toBe(members)
|
||||
})
|
||||
|
||||
it('leaves the turn rail unrendered when an unrelated Chat update commits', () => {
|
||||
const snapshot = chatSnapshotFixture({
|
||||
nodes: [
|
||||
@@ -1875,6 +2021,7 @@ describe('ChatView', () => {
|
||||
|
||||
act(() => {
|
||||
turnData.publish()
|
||||
builder.publish()
|
||||
h.set({ chat: complete, hasMore: false })
|
||||
})
|
||||
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { ChatViewport } from '../src/client/chat/use-chat-viewport.ts'
|
||||
|
||||
function fixture() {
|
||||
const column = document.createElement('div')
|
||||
column.dataset.chatFlow = ''
|
||||
document.body.append(column)
|
||||
const viewport = new ChatViewport()
|
||||
const hitTest = Object.getOwnPropertyDescriptor(document, 'elementsFromPoint')
|
||||
Object.defineProperty(document, 'elementsFromPoint', { configurable: true, value: undefined })
|
||||
onTestFinished(() => {
|
||||
viewport.detach()
|
||||
column.remove()
|
||||
if (hitTest === undefined) Reflect.deleteProperty(document, 'elementsFromPoint')
|
||||
else Object.defineProperty(document, 'elementsFromPoint', hitTest)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
Object.defineProperties(column, {
|
||||
clientHeight: { configurable: true, value: 300 },
|
||||
scrollHeight: { configurable: true, value: 2_000 },
|
||||
})
|
||||
vi.spyOn(column, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 500, 300))
|
||||
viewport.attach(column, column)
|
||||
let prependedHeight = 0
|
||||
const group = () => {
|
||||
const element = document.createElement('div')
|
||||
element.dataset.chatGroupKey = 'group'
|
||||
element.style.display = 'contents'
|
||||
column.append(element)
|
||||
return element
|
||||
}
|
||||
const row = (parent: HTMLElement, nodeKey: string, top: number, part?: string) => {
|
||||
const element = document.createElement('div')
|
||||
element.textContent = nodeKey
|
||||
element.dataset.chatNodeKey = nodeKey
|
||||
element.dataset.chatAnchorKey = part === undefined ? nodeKey : JSON.stringify([nodeKey, part])
|
||||
element.dataset.chatFlowKey = element.dataset.chatAnchorKey
|
||||
element.dataset.chatTurn = '1'
|
||||
parent.append(element)
|
||||
vi.spyOn(element, 'getBoundingClientRect').mockImplementation(() =>
|
||||
new DOMRect(0, top + prependedHeight - column.scrollTop, 500, 60))
|
||||
return element
|
||||
}
|
||||
return { column, viewport, group, row, prepend: (height: number) => { prependedHeight += height } }
|
||||
}
|
||||
|
||||
it('captures a grouped member and preserves its reading position after history grows above it', () => {
|
||||
const h = fixture()
|
||||
const group = h.group()
|
||||
h.row(group, 'first', 20)
|
||||
h.row(group, 'second', 130)
|
||||
h.column.scrollTop = 100
|
||||
|
||||
const position = h.viewport.capturePosition()
|
||||
expect(position).toEqual({ anchorKey: 'second', anchorTop: 30, scrollTop: 100 })
|
||||
if (position === null) throw new Error('expected a grouped reading anchor')
|
||||
h.prepend(200)
|
||||
const landing = h.viewport.preserve(position)
|
||||
expect(landing?.metrics.top).toBe(300)
|
||||
expect(landing?.position).toEqual({ anchorKey: 'second', anchorTop: 30, scrollTop: 300 })
|
||||
})
|
||||
|
||||
it('navigates by Node identity while retaining a distinct visible part anchor', () => {
|
||||
const h = fixture()
|
||||
const group = h.group()
|
||||
const reasoning = h.row(group, 'assistant', 100, 'reasoning')
|
||||
const response = h.row(group, 'assistant', 200, 'response')
|
||||
h.viewport.updateTurns([{ turn: 1, anchorKey: 'assistant', prompt: '', response: '' }])
|
||||
|
||||
expect(h.viewport.scrollToTurn(1)?.position?.anchorKey).toBe(reasoning.dataset.chatAnchorKey)
|
||||
reasoning.setAttribute('hidden', 'until-found')
|
||||
const landing = h.viewport.scrollToTurn(1)
|
||||
expect(landing?.metrics.top).toBe(176)
|
||||
expect(landing?.position?.anchorKey).toBe(response.dataset.chatAnchorKey)
|
||||
expect(h.viewport.restore({ anchorKey: response.dataset.chatAnchorKey!, anchorTop: 8, scrollTop: 0 })?.metrics.top)
|
||||
.toBe(192)
|
||||
})
|
||||
|
||||
it('excludes empty rows, hidden ancestors, and transparent group shells from fallback sampling', () => {
|
||||
const h = fixture()
|
||||
const hidden = h.group()
|
||||
hidden.setAttribute('hidden', 'until-found')
|
||||
h.row(hidden, 'hidden', 10)
|
||||
const group = h.group()
|
||||
group.dataset.chatFlowKey = 'group'
|
||||
group.dataset.chatAnchorKey = 'group'
|
||||
const empty = h.row(group, 'empty', 30)
|
||||
empty.textContent = ''
|
||||
h.row(group, 'visible', 70)
|
||||
|
||||
expect(h.viewport.capturePosition()?.anchorKey).toBe('visible')
|
||||
})
|
||||
|
||||
it('skips hidden group members when navigating to a fallback Turn row', () => {
|
||||
const h = fixture()
|
||||
const hidden = h.group()
|
||||
hidden.setAttribute('hidden', 'until-found')
|
||||
h.row(hidden, 'hidden', 10, 'reasoning')
|
||||
const visible = h.row(h.group(), 'visible', 150, 'response')
|
||||
|
||||
const landing = h.viewport.scrollToTurnAtOrAfter(1)
|
||||
expect(landing?.metrics.top).toBe(126)
|
||||
expect(landing?.position?.anchorKey).toBe(visible.dataset.chatAnchorKey)
|
||||
})
|
||||
|
||||
it('retains the original capture and Turn navigation path for ungrouped Nodes', () => {
|
||||
const h = fixture()
|
||||
h.row(h.column, 'whole', 90)
|
||||
h.viewport.updateTurns([{ turn: 1, anchorKey: 'whole', prompt: '', response: '' }])
|
||||
expect(h.viewport.capturePosition()?.anchorKey).toBe('whole')
|
||||
expect(h.viewport.scrollToTurn(1)?.metrics.top).toBe(66)
|
||||
})
|
||||
@@ -8,6 +8,9 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../util/values"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
@@ -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 packages/client/ui-conversation/README.md
|
||||
README.md: 31b35ef7460c9fecd119e32ae5f696f55585a726
|
||||
README.zh.md: 1fb648bf9539cebc307e094c2fe46b1d1749edab
|
||||
README.md: 6f115521a090ca32686af417a7ceea0634324c7d
|
||||
README.zh.md: babf6f5cfb0760b471988cf27017e20b46a85868
|
||||
@@ -31,6 +31,10 @@ The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its o
|
||||
|
||||
A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it.
|
||||
|
||||
`UiConversation.groups` registers one optional business Group Definition per existing target. It consumes projected Node changes, changed Turns, and indexed target positions after Node materialization, including first activation, and owns all segmentation and group data. Indexed Turn reads preserve interruptions from neighbouring Nodes, allowing business updates to stay within affected Turns and groups. The assembler validates and installs root references and keyed group snapshots before publishing Node, Group, and Location data sources. Ungrouped targets retain their original path. [Grouping](../../../docs/subsystems/conversation.md#group-definitions) defines input lifetime, typed registration, atomic updates, and renderer responsibilities.
|
||||
|
||||
Group registration rejects a missing View target. Removing an already registered View Definition pauses its grouping and clears its published results without removing the Group Definition; registering that View again rebuilds from the current loaded timeline through the existing replacement flow. Switching View tabs does not remove a View Definition.
|
||||
|
||||
Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry. The shared request inspection serves every target: `ctx.uiConversation.inspectSystemPrompt(previous, event)` interprets system messages and positional replacements as immutable loaded-surface state. It selects the last nonempty surviving system node in surface order, retains only surviving replacement positions for chained rewrites, and withholds the prompt after an unindexed older endpoint until prepend replay supplies its order. Target-owned Definitions retain historical cards independently. `ctx.uiConversation.inspectRequestPrompt(previous, header, system)` classifies request changes against that effective prompt; ordinary messages and stream chunks require no system-state work.
|
||||
|
||||
<a id="shell-and-standard-props"></a>
|
||||
|
||||
@@ -31,6 +31,10 @@ kind: "package-reference"
|
||||
|
||||
shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。
|
||||
|
||||
`UiConversation.groups` 为每个已注册目标注册一个可选的业务 Group Definition。它在节点物化后消费投影后的节点变化、变化轮次及已索引的目标位置,覆盖首次激活,并拥有全部分段规则与组数据。按索引读取 Turn 时保留相邻 Node 造成的分隔,使业务更新可以限制在受影响轮次和组内。assembler 先校验并安装根引用和按键索引的组快照,再发布 Node、Group 和 Location 数据来源。未分组目标保持原有路径。[分组](../../../docs/subsystems/conversation.zh.md#group-definitions)定义输入有效期、类型化注册、原子更新及渲染器职责。
|
||||
|
||||
Group 注册时缺少 View 目标会报错。已注册的 View Definition 被移除后,其分组计算暂停并清空已发布结果,但保留 Group Definition;重新注册该 View 后,沿现有替换流程从当前已加载时间线重建。切换 View 页签不会移除 View Definition。
|
||||
|
||||
target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。共享的请求检查服务于每个 target:`ctx.uiConversation.inspectSystemPrompt(previous, event)` 将系统消息与位置替换解释为不可变的已加载 surface 状态。它按 surface 顺序选择最后一个非空的存活系统节点,为连续重写只保留存活的替换位置;遇到未建立索引的更早端点后,提示词保持不可用,直到向前补页回放提供其顺序。target 自有的 Definition 独立保留历史卡片。`ctx.uiConversation.inspectRequestPrompt(previous, header, system)` 根据该有效提示词分类请求变更;普通消息与流式分片无需处理系统状态。
|
||||
|
||||
<a id="shell-and-standard-props"></a>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { SessionEventLike } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
ConversationGroupData, ConversationGroupedView, ConversationGroupInput,
|
||||
} from './groups.ts'
|
||||
|
||||
/** Definition-local identity and lifecycle role extracted from one event. */
|
||||
export interface ConversationMatchResult {
|
||||
@@ -141,6 +144,14 @@ export interface ConversationViewSnapshotStore {
|
||||
get<Target extends Extract<keyof ConversationViewSnapshotMap, string>>(
|
||||
target: Target,
|
||||
): ConversationViewSnapshotMap[Target] | undefined
|
||||
/**
|
||||
* Read grouping for an activated target without activating another target.
|
||||
* @param target - registered View target.
|
||||
* @returns its grouped reader, when a Group Definition is active.
|
||||
*/
|
||||
grouped<Target extends string>(
|
||||
target: Target,
|
||||
): ConversationGroupedView<ConversationGroupData<Target>> | undefined
|
||||
}
|
||||
|
||||
/** Immutable public view of an assembled business Context. */
|
||||
@@ -261,6 +272,7 @@ export interface ConversationViewBuilder<Node extends ConversationViewNode = Con
|
||||
replace(input: {
|
||||
readonly nodes: readonly Node[]
|
||||
readonly timeline: ConversationTimelineSnapshot
|
||||
readonly changedTurns?: readonly number[]
|
||||
}): Snapshot
|
||||
/**
|
||||
* Apply only Nodes whose materialized values changed in this transaction.
|
||||
@@ -270,7 +282,12 @@ export interface ConversationViewBuilder<Node extends ConversationViewNode = Con
|
||||
apply(input: {
|
||||
readonly upserts: readonly Node[]
|
||||
readonly timeline: ConversationTimelineSnapshot
|
||||
readonly changedTurns?: readonly number[]
|
||||
}): Snapshot
|
||||
/** @returns the latest target-processed Node inputs; required only for a grouped target. */
|
||||
groupInput?(): ConversationGroupInput<Node>
|
||||
/** Publish local sources after all target snapshots and grouping results have been installed. */
|
||||
publish?(): void
|
||||
}
|
||||
|
||||
/** Registry contribution that creates an isolated builder when a Session first uses this target. */
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/** Definition-owned grouping over a target's already materialized Nodes. */
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ConversationTimelineSnapshot, ConversationViewNode } from './conversation.ts'
|
||||
|
||||
/** Existing Node identity, without allocating another execution Node. */
|
||||
export type NodeKey = Branded<'ConversationNodeKey'>
|
||||
|
||||
/** Definition-local group identity within one Session and target. */
|
||||
export type GroupKey = Branded<'ConversationGroupKey'>
|
||||
|
||||
/** One whole Node or a renderer-owned part of that Node. */
|
||||
export interface NodeReference {
|
||||
readonly kind: 'node'
|
||||
readonly key: NodeKey
|
||||
readonly groupPart?: string
|
||||
}
|
||||
|
||||
/** Root-list reference to a separately observed group. */
|
||||
export interface GroupReference {
|
||||
readonly kind: 'group'
|
||||
readonly key: GroupKey
|
||||
}
|
||||
|
||||
/** The two supported root rendering positions. */
|
||||
export type RenderEntry = NodeReference | GroupReference
|
||||
|
||||
/** Immutable group data and its ordered, non-nested Node references. */
|
||||
export interface GroupSnapshot<Data> {
|
||||
readonly key: GroupKey
|
||||
readonly data: Data
|
||||
readonly members: readonly NodeReference[]
|
||||
}
|
||||
|
||||
/**
|
||||
* One atomic grouping update. Replacement input requires entries and a complete
|
||||
* group replacement; apply input may omit entries to preserve the root list.
|
||||
* Group removal never deletes source Nodes.
|
||||
*/
|
||||
export interface GroupUpdate<Data> {
|
||||
/** Complete root sequence; only its Node references and referenced group members render. */
|
||||
readonly entries?: readonly RenderEntry[]
|
||||
readonly groups:
|
||||
| { readonly kind: 'replace'; readonly snapshots: readonly GroupSnapshot<Data>[] }
|
||||
| {
|
||||
readonly kind: 'apply'
|
||||
readonly upserts: readonly GroupSnapshot<Data>[]
|
||||
readonly removes: readonly GroupKey[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Target-processed Node values before and after one update. */
|
||||
export interface NodeChange<Node> {
|
||||
readonly previous: Node | undefined
|
||||
readonly current: Node
|
||||
}
|
||||
|
||||
/** Owning Turn and immediate neighbours in the target's visible Node order. */
|
||||
export interface GroupNodePosition {
|
||||
readonly turn: number | undefined
|
||||
readonly previous: NodeKey | undefined
|
||||
readonly next: NodeKey | undefined
|
||||
}
|
||||
|
||||
interface GroupInputNodes<Node> {
|
||||
readonly order: readonly NodeKey[]
|
||||
/** @param key - target Node identity. @returns its current value during this synchronous update. */
|
||||
readonly readNode: (key: NodeKey) => Node | undefined
|
||||
/** @param turn - owning Turn. @returns its visible keys in target order; empty when absent. */
|
||||
readonly readTurn: (turn: number) => readonly NodeKey[]
|
||||
/** @param key - target Node identity. @returns its visible position, or absence outside the target order. */
|
||||
readonly readPosition: (key: NodeKey) => GroupNodePosition | undefined
|
||||
readonly timeline: ConversationTimelineSnapshot
|
||||
}
|
||||
|
||||
/** Target-local inputs; apply includes lifecycle changes without Node upserts. */
|
||||
export type ConversationGroupInput<Node> = GroupInputNodes<Node> & (
|
||||
| { readonly kind: 'replace' }
|
||||
| {
|
||||
readonly kind: 'apply'
|
||||
readonly changes: readonly NodeChange<Node>[]
|
||||
readonly changedTurns: readonly number[]
|
||||
/** Turns whose visible keys or immediate neighbours changed, including removal and cross-Turn moves. */
|
||||
readonly changedTurnOrders: readonly number[]
|
||||
}
|
||||
)
|
||||
|
||||
/** Framework-owned State for one Session's registered Group Definition. */
|
||||
export interface ConversationGroupContext<State> {
|
||||
readonly state: State
|
||||
}
|
||||
|
||||
/** Business grouping rules, driven after the target Builder has installed Nodes. */
|
||||
export interface ConversationGroupDefinition<
|
||||
Node extends ConversationViewNode = ConversationViewNode,
|
||||
State = unknown,
|
||||
Data = unknown,
|
||||
> {
|
||||
readonly kind: string
|
||||
readonly target: string
|
||||
/** @returns initial Session-local grouping State. */
|
||||
create(): State
|
||||
/**
|
||||
* Consume target Node changes, not raw Session events or presentation modes.
|
||||
* @param context - current Session-local State.
|
||||
* @param input - current target inputs; its readers are valid only during this call.
|
||||
* @returns State adopted by the framework.
|
||||
*/
|
||||
update(context: ConversationGroupContext<State>, input: ConversationGroupInput<Node>): State
|
||||
/**
|
||||
* Materialize pending grouping changes without changing the business State.
|
||||
* @param context - State adopted after update.
|
||||
* @returns complete output for replace input; an update or null when unchanged for apply input.
|
||||
*/
|
||||
buildGroups(context: ConversationGroupContext<State>): GroupUpdate<Data> | null
|
||||
}
|
||||
|
||||
/** Merge-extensible group data associated with each registered target. */
|
||||
export interface ConversationGroupDataMap {}
|
||||
|
||||
/** Group data declared by a target; undeclared targets have no group payload. */
|
||||
export type ConversationGroupData<Target extends string> =
|
||||
Target extends keyof ConversationGroupDataMap ? ConversationGroupDataMap[Target] : never
|
||||
|
||||
/** Stable grouped reader; data-only updates preserve entries and member arrays. */
|
||||
export interface ConversationGroupedView<Data> {
|
||||
readonly entries: readonly RenderEntry[]
|
||||
/**
|
||||
* Observe a group without subscribing to the root sequence.
|
||||
* @param key - group identity within this target.
|
||||
* @returns an identity-stable source, whose value is absent after removal.
|
||||
*/
|
||||
groupSource(key: GroupKey): ObservableSnapshot<GroupSnapshot<Data> | undefined>
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export interface ConversationSnapshot {
|
||||
|
||||
/** Empty Conversation value used before a Session binding is available. */
|
||||
export const EMPTY_CONVERSATION_SNAPSHOT: ConversationSnapshot = {
|
||||
views: { get: () => undefined },
|
||||
views: { get: () => undefined, grouped: () => undefined },
|
||||
activeTargets: new Set(),
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
ConversationViewSnapshotStore,
|
||||
} from '../contract/conversation.ts'
|
||||
import { conversationContextKey } from '../contract/conversation.ts'
|
||||
import type {
|
||||
ConversationGroupData, ConversationGroupDefinition, ConversationGroupedView,
|
||||
} from '../contract/groups.ts'
|
||||
import { ConversationGroupStore } from './group-store.ts'
|
||||
import {
|
||||
ConversationLocationIndex, type ConversationLocationDataChange,
|
||||
} from './location-index.ts'
|
||||
@@ -46,11 +50,18 @@ interface PendingMatch {
|
||||
interface ViewState {
|
||||
readonly target: string
|
||||
readonly definition: ConversationViewDefinition
|
||||
readonly groupDefinition: ConversationGroupDefinition | undefined
|
||||
readonly isActive: ((snapshot: unknown) => boolean) | undefined
|
||||
builder: ConversationViewBuilder | undefined
|
||||
snapshot: unknown
|
||||
}
|
||||
|
||||
interface GroupState {
|
||||
readonly definition: ConversationGroupDefinition
|
||||
state: unknown
|
||||
readonly store: ConversationGroupStore<unknown>
|
||||
}
|
||||
|
||||
const PUBLICATION_RANK: Record<ConversationPublication, number> = {
|
||||
none: 0,
|
||||
'animation-frame': 1,
|
||||
@@ -152,6 +163,16 @@ export interface ConversationViewDefinitions {
|
||||
entries(): readonly ConversationViewDefinition[]
|
||||
}
|
||||
|
||||
/** Registered grouping rules consumed by the target-neutral assembler. */
|
||||
export interface ConversationGroupDefinitions {
|
||||
/** @returns grouping Definitions in registration order. */
|
||||
entries(): readonly ConversationGroupDefinition[]
|
||||
/** @param target - View target. @returns its optional Group Definition. */
|
||||
forTarget(target: string): ConversationGroupDefinition | undefined
|
||||
}
|
||||
|
||||
const NO_GROUPS: ConversationGroupDefinitions = { entries: () => [], forTarget: () => undefined }
|
||||
|
||||
/**
|
||||
* Session-owned incremental engine that assembles business Contexts from a
|
||||
* contiguous Event window and materializes registered view snapshots.
|
||||
@@ -168,6 +189,8 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
private readonly revised = new Set<InternalContext>()
|
||||
private readonly dependents = new Map<string, Set<InternalContext>>()
|
||||
private readonly views = new Map<string, ViewState>()
|
||||
private readonly groups = new Map<string, GroupState>()
|
||||
private readonly pendingGroupStores = new Set<ConversationGroupStore<unknown>>()
|
||||
private readonly activeTargets = new Set<string>()
|
||||
private hasMore = false
|
||||
private replacePending = true
|
||||
@@ -176,10 +199,12 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
/**
|
||||
* @param eventDefinitions - live Event Definition registry.
|
||||
* @param viewDefinitions - live view builder registry.
|
||||
* @param groupDefinitions - optional registered grouping rules, independent of presentation modes.
|
||||
*/
|
||||
constructor(
|
||||
private readonly eventDefinitions: ConversationEventDefinitions,
|
||||
private readonly viewDefinitions: ConversationViewDefinitions,
|
||||
private readonly groupDefinitions: ConversationGroupDefinitions = NO_GROUPS,
|
||||
) {
|
||||
this.resetViewBuilders()
|
||||
}
|
||||
@@ -333,28 +358,24 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
if (!this.replacePending && this.dirty.size === 0 && !this.timelineDirty) return false
|
||||
if (this.replacePending) {
|
||||
this.replaceLocationData()
|
||||
let published = false
|
||||
const updated: ViewState[] = []
|
||||
const changedTurns = this.locationIndex.takeChangedTurns()
|
||||
for (const target of this.activeTargets) {
|
||||
const view = this.views.get(target)
|
||||
if (view === undefined) continue
|
||||
const builder = view.builder ?? view.definition.create()
|
||||
view.builder = builder
|
||||
view.snapshot = builder.replace({
|
||||
nodes: this.buildTargetNodes(target, this.contextsByTarget.get(target)),
|
||||
timeline: this.locationIndex.snapshot(),
|
||||
})
|
||||
published = true
|
||||
this.updateView(view, true, this.buildTargetNodes(target, this.contextsByTarget.get(target)), changedTurns)
|
||||
updated.push(view)
|
||||
}
|
||||
this.locationIndex.publishData()
|
||||
this.replacePending = false
|
||||
this.dirty.clear()
|
||||
this.dirtyByTarget.clear()
|
||||
this.timelineDirty = false
|
||||
return published
|
||||
return this.publishViews(updated)
|
||||
}
|
||||
|
||||
let published = false
|
||||
const updated: ViewState[] = []
|
||||
if (this.applyDirtyLocationData()) this.timelineDirty = true
|
||||
const changedTurns = this.locationIndex.takeChangedTurns()
|
||||
const timelineDirty = this.timelineDirty
|
||||
for (const target of this.activeTargets) {
|
||||
const view = this.views.get(target)
|
||||
@@ -363,17 +384,13 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
if (builder === undefined) continue
|
||||
const upserts = this.buildTargetUpserts(target, this.dirtyByTarget.get(target))
|
||||
if (upserts.length === 0 && !timelineDirty) continue
|
||||
view.snapshot = builder.apply({
|
||||
upserts,
|
||||
timeline: this.locationIndex.snapshot(),
|
||||
})
|
||||
published = true
|
||||
this.updateView(view, false, upserts, changedTurns)
|
||||
updated.push(view)
|
||||
}
|
||||
this.locationIndex.publishData()
|
||||
this.dirty.clear()
|
||||
this.dirtyByTarget.clear()
|
||||
this.timelineDirty = false
|
||||
return published
|
||||
return this.publishViews(updated)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,6 +406,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
this.activeTargets.add(target)
|
||||
if (view === undefined) return published
|
||||
this.replaceView(view)
|
||||
this.publishViews([view])
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -407,6 +425,12 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
return this.snapshot(target) as ConversationViewSnapshotMap[Target] | undefined
|
||||
}
|
||||
|
||||
grouped<Target extends string>(
|
||||
target: Target,
|
||||
): ConversationGroupedView<ConversationGroupData<Target>> | undefined {
|
||||
return this.groups.get(target)?.store as ConversationGroupedView<ConversationGroupData<Target>> | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Read targets whose owners classify their latest snapshot as visible activity.
|
||||
* @returns target ids contributing visible activity.
|
||||
@@ -827,12 +851,55 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
}
|
||||
|
||||
private replaceView(view: ViewState): void {
|
||||
this.updateView(view, true, this.buildTargetNodes(view.target, this.contextsByTarget.get(view.target)), [])
|
||||
}
|
||||
|
||||
private updateView(
|
||||
view: ViewState,
|
||||
replacing: boolean,
|
||||
nodes: readonly ConversationViewNode[],
|
||||
changedTurns: readonly number[],
|
||||
): void {
|
||||
const builder = view.builder ?? view.definition.create()
|
||||
const definition = view.groupDefinition
|
||||
if (definition !== undefined && builder.groupInput === undefined) {
|
||||
throw new Error(`conversation group target "${view.target}" requires builder.groupInput()`)
|
||||
}
|
||||
view.builder = builder
|
||||
view.snapshot = builder.replace({
|
||||
nodes: this.buildTargetNodes(view.target, this.contextsByTarget.get(view.target)),
|
||||
timeline: this.locationIndex.snapshot(),
|
||||
})
|
||||
const timeline = this.locationIndex.snapshot()
|
||||
const snapshot = replacing
|
||||
? builder.replace({ nodes, timeline, changedTurns })
|
||||
: builder.apply({ upserts: nodes, timeline, changedTurns })
|
||||
if (definition !== undefined && builder.groupInput !== undefined) {
|
||||
let context = this.groups.get(view.target)
|
||||
const initial = context === undefined
|
||||
if (context === undefined) {
|
||||
context = { definition, state: definition.create(), store: new ConversationGroupStore() }
|
||||
}
|
||||
const input = builder.groupInput()
|
||||
context.state = definition.update(context, input)
|
||||
const change = definition.buildGroups(context)
|
||||
if ((initial || input.kind === 'replace')
|
||||
&& (change === null || change.entries === undefined || change.groups.kind !== 'replace')) {
|
||||
throw new Error(`conversation group target "${view.target}" requires complete grouping for replacement input`)
|
||||
}
|
||||
if (change !== null) {
|
||||
context.store.prepareAndInstall(change, input.readNode)
|
||||
this.pendingGroupStores.add(context.store)
|
||||
}
|
||||
this.groups.set(view.target, context)
|
||||
}
|
||||
view.snapshot = snapshot
|
||||
}
|
||||
|
||||
private publishViews(updated: readonly ViewState[]): boolean {
|
||||
const changed = updated.length > 0 || this.pendingGroupStores.size > 0
|
||||
const stores = [...this.pendingGroupStores]
|
||||
this.pendingGroupStores.clear()
|
||||
for (const view of updated) view.builder?.publish?.()
|
||||
for (const store of stores) store.publish()
|
||||
this.locationIndex.publishData()
|
||||
return changed
|
||||
}
|
||||
|
||||
private buildTargetNodes(
|
||||
@@ -925,11 +992,21 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
|
||||
}
|
||||
|
||||
private resetViewBuilders(): void {
|
||||
const definitions = this.viewDefinitions.entries()
|
||||
const targets = new Set(definitions.map(definition => definition.target))
|
||||
for (const [target, group] of this.groups) {
|
||||
if (!targets.has(target) || this.groupDefinitions.forTarget(target) !== group.definition) {
|
||||
group.store.clear()
|
||||
this.pendingGroupStores.add(group.store)
|
||||
this.groups.delete(target)
|
||||
}
|
||||
}
|
||||
this.views.clear()
|
||||
for (const definition of this.viewDefinitions.entries()) {
|
||||
for (const definition of definitions) {
|
||||
const view: ViewState = {
|
||||
target: definition.target,
|
||||
definition,
|
||||
groupDefinition: this.groupDefinitions.forTarget(definition.target),
|
||||
isActive: definition.isActive === undefined
|
||||
? undefined
|
||||
: snapshot => definition.isActive?.(snapshot) === true,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { ConversationNodeAssembler } from './assembler.ts'
|
||||
import { ConversationEventRegistry } from './event-registry.ts'
|
||||
import { HistoricalImageCache } from './historical-images.ts'
|
||||
import { ConversationViewRegistry } from './view-registry.ts'
|
||||
import { ConversationGroupRegistry } from './group-registry.ts'
|
||||
|
||||
/** Observable faces published for one Session's Conversation assembly. */
|
||||
export interface ConversationBinding {
|
||||
@@ -178,6 +179,8 @@ export class UiConversation extends Service {
|
||||
readonly events: ConversationEventRegistry
|
||||
/** Registry of target View definitions. */
|
||||
readonly views: ConversationViewRegistry
|
||||
/** Business grouping rules over already materialized target Nodes. */
|
||||
readonly groups: ConversationGroupRegistry
|
||||
private readonly bindings = new WeakMapWithValues<SessionBinding, BindingRecord>()
|
||||
private readonly images: HistoricalImageCache
|
||||
|
||||
@@ -189,6 +192,7 @@ export class UiConversation extends Service {
|
||||
super(ctx, 'uiConversation')
|
||||
this.events = new ConversationEventRegistry(ctx)
|
||||
this.views = new ConversationViewRegistry(ctx)
|
||||
this.groups = new ConversationGroupRegistry(ctx, this.views)
|
||||
this.images = new HistoricalImageCache(ctx, sessions)
|
||||
const rebuild = (): void => {
|
||||
for (const record of this.bindings.values) record.binding.rebuild()
|
||||
@@ -205,7 +209,9 @@ export class UiConversation extends Service {
|
||||
ctx.effect(() => {
|
||||
const disposeEvents = this.events.subscribe(scheduleRebuild)
|
||||
const disposeViews = this.views.subscribe(scheduleRebuild)
|
||||
const disposeGroups = this.groups.subscribe(scheduleRebuild)
|
||||
return () => {
|
||||
disposeGroups()
|
||||
disposeViews()
|
||||
disposeEvents()
|
||||
for (const record of [...this.bindings.values]) this.drop(record, true)
|
||||
@@ -229,7 +235,7 @@ export class UiConversation extends Service {
|
||||
if (current !== undefined) return current.binding
|
||||
const binding = new BoundConversation(
|
||||
owner.eventSource,
|
||||
new ConversationNodeAssembler(this.events, this.views),
|
||||
new ConversationNodeAssembler(this.events, this.views, this.groups),
|
||||
)
|
||||
const record: BindingRecord = { source: owner, binding, disposeScope: () => {} }
|
||||
this.bindings.set(owner, record)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/** Target-indexed business grouping registrations with the existing effect lifecycle. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ConversationViewNode } from '../contract/conversation.ts'
|
||||
import type {
|
||||
ConversationGroupData, ConversationGroupDefinition,
|
||||
} from '../contract/groups.ts'
|
||||
import { ConversationDefinitionRegistry } from './definition-registry.ts'
|
||||
import type { ConversationViewRegistry } from './view-registry.ts'
|
||||
|
||||
/** One Group Definition per target; registration never instantiates its Builder. */
|
||||
export class ConversationGroupRegistry extends ConversationDefinitionRegistry<ConversationGroupDefinition> {
|
||||
/**
|
||||
* @param ctx - owning plugin context.
|
||||
* @param views - registered target Builder definitions.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly views: ConversationViewRegistry) {
|
||||
super(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register grouping rules for an existing target.
|
||||
* @param definition - business State and grouping output for the declared target data.
|
||||
* @returns the effect-owned, idempotent registration disposer.
|
||||
*/
|
||||
register<Node extends ConversationViewNode, State, Target extends string>(
|
||||
definition: ConversationGroupDefinition<Node, State, ConversationGroupData<Target>>
|
||||
& { readonly target: Target },
|
||||
): () => void {
|
||||
if (!this.views.entries().some(view => view.target === definition.target)) {
|
||||
throw new Error(`conversation group target "${definition.target}" is not registered`)
|
||||
}
|
||||
return this.registerDefinition(
|
||||
definition.target,
|
||||
definition,
|
||||
`conversation group target "${definition.target}" is already registered`,
|
||||
`uiConversation.groups.register(${JSON.stringify(definition.target)})`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the grouping rules registered for one target.
|
||||
* @param target - View target.
|
||||
* @returns its grouping Definition, when registered.
|
||||
*/
|
||||
forTarget(target: string): ConversationGroupDefinition | undefined {
|
||||
return this.definitions.get(target)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/** Keyed group publication and incremental validation of rendering positions. */
|
||||
import { createSnapshotStore, type ObservableSnapshot, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import type { ConversationViewNode } from '../contract/conversation.ts'
|
||||
import type {
|
||||
ConversationGroupedView, GroupKey, GroupSnapshot, GroupUpdate, NodeKey, NodeReference, RenderEntry,
|
||||
} from '../contract/groups.ts'
|
||||
|
||||
type Parts = Set<string | undefined>
|
||||
|
||||
interface GroupSource<Data> {
|
||||
readonly observable: ObservableSnapshot<GroupSnapshot<Data> | undefined>
|
||||
readonly publication: SnapshotStore<GroupSnapshot<Data> | undefined>
|
||||
}
|
||||
|
||||
interface GroupChanges<Data> {
|
||||
readonly upserts: Map<GroupKey, GroupSnapshot<Data>>
|
||||
readonly removes: Set<GroupKey>
|
||||
}
|
||||
|
||||
function sameNodeReference(left: NodeReference, right: NodeReference): boolean {
|
||||
return left.key === right.key && left.groupPart === right.groupPart
|
||||
}
|
||||
|
||||
function sameEntry(left: RenderEntry, right: RenderEntry): boolean {
|
||||
return left.kind === right.kind && left.key === right.key
|
||||
&& (left.kind === 'group' || (right.kind === 'node' && left.groupPart === right.groupPart))
|
||||
}
|
||||
|
||||
function reuseReferences<Value>(
|
||||
previous: readonly Value[],
|
||||
next: readonly Value[],
|
||||
equal: (left: Value, right: Value) => boolean,
|
||||
): readonly Value[] {
|
||||
return previous === next || (previous.length === next.length
|
||||
&& previous.every((value, index) => equal(value, next[index] as Value)))
|
||||
? previous
|
||||
: next
|
||||
}
|
||||
|
||||
/** Validates a complete batch before installing its group and root-list changes. */
|
||||
export class ConversationGroupStore<Data> implements ConversationGroupedView<Data> {
|
||||
private root: readonly RenderEntry[] = []
|
||||
private rootGroups = new Set<GroupKey>()
|
||||
private readonly groups = new Map<GroupKey, GroupSnapshot<Data>>()
|
||||
private readonly placements = new Map<NodeKey, Parts>()
|
||||
private readonly sources = new Map<GroupKey, GroupSource<Data>>()
|
||||
private readonly dirty = new Set<GroupKey>()
|
||||
|
||||
/** @returns the identity-stable ordered root references. */
|
||||
get entries(): readonly RenderEntry[] {
|
||||
return this.root
|
||||
}
|
||||
|
||||
groupSource(key: GroupKey): ObservableSnapshot<GroupSnapshot<Data> | undefined> {
|
||||
let source = this.sources.get(key)
|
||||
if (source === undefined) {
|
||||
const publication = createSnapshotStore(this.groups.get(key))
|
||||
source = {
|
||||
publication,
|
||||
observable: {
|
||||
getSnapshot: () => this.groups.get(key),
|
||||
subscribe: listener => publication.subscribe(listener),
|
||||
},
|
||||
}
|
||||
this.sources.set(key, source)
|
||||
}
|
||||
return source.observable
|
||||
}
|
||||
|
||||
/**
|
||||
* Install one validated update without notifying readers.
|
||||
* @param update - root replacement and complete or incremental group records.
|
||||
* @param readNode - synchronous reader of the current target Nodes.
|
||||
*/
|
||||
prepareAndInstall(
|
||||
update: GroupUpdate<Data>,
|
||||
readNode: (key: NodeKey) => ConversationViewNode | undefined,
|
||||
): void {
|
||||
const nextRoot = update.entries === undefined
|
||||
? this.root
|
||||
: reuseReferences(this.root, update.entries, sameEntry)
|
||||
const nextRootGroups = nextRoot === this.root ? this.rootGroups : this.collectRootGroups(nextRoot)
|
||||
const { upserts, removes } = this.collectChanges(update)
|
||||
const replaceReferences = update.groups.kind === 'replace'
|
||||
const size = this.groups.size - removes.size
|
||||
+ [...upserts.keys()].filter(key => !this.groups.has(key)).length
|
||||
if (size !== nextRootGroups.size) {
|
||||
throw new Error('conversation group records and root references must correspond one-to-one')
|
||||
}
|
||||
for (const key of removes) {
|
||||
if (nextRootGroups.has(key)) throw new Error(`conversation group "${key}" is still referenced`)
|
||||
}
|
||||
for (const key of upserts.keys()) {
|
||||
if (!nextRootGroups.has(key)) throw new Error(`conversation group "${key}" has no root reference`)
|
||||
}
|
||||
if (nextRootGroups !== this.rootGroups) {
|
||||
for (const key of nextRootGroups) {
|
||||
if (!upserts.has(key) && !this.groups.has(key)) {
|
||||
throw new Error(`conversation root references missing group "${key}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const affectedParts = new Map<NodeKey, Parts>()
|
||||
const partsOf = (key: NodeKey): Parts => {
|
||||
let parts = affectedParts.get(key)
|
||||
if (parts === undefined) {
|
||||
parts = new Set(this.placements.get(key))
|
||||
affectedParts.set(key, parts)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
const removeReference = (reference: NodeReference): void => {
|
||||
partsOf(reference.key).delete(reference.groupPart)
|
||||
}
|
||||
const addReference = (reference: NodeReference): void => {
|
||||
if (readNode(reference.key) === undefined) {
|
||||
throw new Error(`conversation group references missing Node "${reference.key}"`)
|
||||
}
|
||||
const parts = partsOf(reference.key)
|
||||
if (parts.has(reference.groupPart)
|
||||
|| (reference.groupPart === undefined && parts.size > 0)
|
||||
|| parts.has(undefined)) {
|
||||
throw new Error(`conversation Node "${reference.key}" has overlapping rendering positions`)
|
||||
}
|
||||
parts.add(reference.groupPart)
|
||||
}
|
||||
|
||||
if (replaceReferences || nextRoot !== this.root) {
|
||||
for (const entry of this.root) if (entry.kind === 'node') removeReference(entry)
|
||||
}
|
||||
for (const key of removes) {
|
||||
for (const member of (this.groups.get(key) as GroupSnapshot<Data>).members) removeReference(member)
|
||||
}
|
||||
for (const [key, next] of upserts) {
|
||||
const previous = this.groups.get(key)
|
||||
if (previous !== undefined && (replaceReferences || previous.members !== next.members)) {
|
||||
for (const member of previous.members) removeReference(member)
|
||||
}
|
||||
}
|
||||
if (replaceReferences || nextRoot !== this.root) {
|
||||
for (const entry of nextRoot) if (entry.kind === 'node') addReference(entry)
|
||||
}
|
||||
for (const [key, next] of upserts) {
|
||||
if (replaceReferences || this.groups.get(key)?.members !== next.members) {
|
||||
for (const member of next.members) addReference(member)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, parts] of affectedParts) {
|
||||
if (parts.size === 0) this.placements.delete(key)
|
||||
else this.placements.set(key, parts)
|
||||
}
|
||||
for (const key of removes) {
|
||||
this.groups.delete(key)
|
||||
this.dirty.add(key)
|
||||
}
|
||||
for (const [key, next] of upserts) {
|
||||
if (this.groups.get(key) === next) continue
|
||||
this.groups.set(key, next)
|
||||
this.dirty.add(key)
|
||||
}
|
||||
this.root = nextRoot
|
||||
this.rootGroups = nextRootGroups
|
||||
}
|
||||
|
||||
/** Publish changed group sources after all related target data has been installed. */
|
||||
publish(): void {
|
||||
const keys = [...this.dirty]
|
||||
this.dirty.clear()
|
||||
for (const key of keys) this.sources.get(key)?.publication.set(this.groups.get(key))
|
||||
}
|
||||
|
||||
/** Remove grouping without deleting its source Nodes; publication remains deferred. */
|
||||
clear(): void {
|
||||
this.prepareAndInstall(
|
||||
{ entries: [], groups: { kind: 'replace', snapshots: [] } },
|
||||
/* v8 ignore next -- an empty replacement never reads a Node reference. */
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
|
||||
private collectRootGroups(entries: readonly RenderEntry[]): Set<GroupKey> {
|
||||
const keys = new Set<GroupKey>()
|
||||
for (const entry of entries) {
|
||||
if (entry.kind !== 'group') continue
|
||||
if (keys.has(entry.key)) throw new Error(`conversation group "${entry.key}" has duplicate root references`)
|
||||
keys.add(entry.key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
private collectChanges(update: GroupUpdate<Data>): GroupChanges<Data> {
|
||||
const upserts = new Map<GroupKey, GroupSnapshot<Data>>()
|
||||
const removes = new Set<GroupKey>()
|
||||
const add = (snapshot: GroupSnapshot<Data>): void => {
|
||||
if (upserts.has(snapshot.key)) throw new Error(`conversation group "${snapshot.key}" has duplicate upserts`)
|
||||
const previous = this.groups.get(snapshot.key)
|
||||
if (previous === undefined) {
|
||||
upserts.set(snapshot.key, snapshot)
|
||||
return
|
||||
}
|
||||
const members = reuseReferences(previous.members, snapshot.members, sameNodeReference)
|
||||
upserts.set(snapshot.key, previous.data === snapshot.data && previous.members === members
|
||||
? previous
|
||||
: { ...snapshot, members })
|
||||
}
|
||||
switch (update.groups.kind) {
|
||||
case 'replace':
|
||||
for (const snapshot of update.groups.snapshots) add(snapshot)
|
||||
for (const key of this.groups.keys()) if (!upserts.has(key)) removes.add(key)
|
||||
break
|
||||
case 'apply':
|
||||
for (const snapshot of update.groups.upserts) add(snapshot)
|
||||
for (const key of update.groups.removes) {
|
||||
if (removes.has(key)) throw new Error(`conversation group "${key}" has duplicate removals`)
|
||||
if (!this.groups.has(key)) throw new Error(`conversation group "${key}" cannot be removed because it is absent`)
|
||||
if (upserts.has(key)) throw new Error(`conversation group "${key}" cannot be upserted and removed together`)
|
||||
removes.add(key)
|
||||
}
|
||||
break
|
||||
/* v8 ignore next 2 -- closed update union; TypeScript rejects other operation tags. */
|
||||
default:
|
||||
assertNever(update.groups)
|
||||
}
|
||||
return { upserts, removes }
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,7 @@ export class ConversationLocationIndex {
|
||||
private readonly turnDataStores = new Map<number, MutableLocationDataStore>()
|
||||
private readonly stepDataStores = new Map<string, MutableLocationDataStore>()
|
||||
private readonly dirtyDataStores = new Set<MutableLocationDataStore>()
|
||||
private readonly changedTurns = new Set<number>()
|
||||
private currentTurn: number | undefined
|
||||
private currentStep: number | undefined
|
||||
|
||||
@@ -194,6 +195,16 @@ export class ConversationLocationIndex {
|
||||
return this.timeline
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the Turn changes accumulated for one assembly flush.
|
||||
* @returns Turn identities changed since the preceding drain.
|
||||
*/
|
||||
takeChangedTurns(): readonly number[] {
|
||||
const turns = [...this.changedTurns]
|
||||
this.changedTurns.clear()
|
||||
return turns
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all Definition-owned Location values while preserving reader identities.
|
||||
* @param entries - complete current set of Definition-owned Location values.
|
||||
@@ -386,6 +397,9 @@ export class ConversationLocationIndex {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const turn of new Set([...previousTurns.keys(), ...nextTurns.keys()])) {
|
||||
if (previousTurns.get(turn) !== nextTurns.get(turn)) this.changedTurns.add(turn)
|
||||
}
|
||||
this.timeline = sameMap && turnOrder === this.timeline.turnOrder
|
||||
? this.timeline
|
||||
: { turnOrder, turns: nextTurns }
|
||||
@@ -480,6 +494,7 @@ export class ConversationLocationIndex {
|
||||
? [...this.timeline.turnOrder, turnNumber]
|
||||
: this.timeline.turnOrder
|
||||
this.timeline = { turnOrder, turns }
|
||||
if (turn !== previousTurn) this.changedTurns.add(turnNumber)
|
||||
|
||||
const changed = new Set<number>()
|
||||
for (const seq of this.seqsByTurn.get(turnNumber) ?? []) {
|
||||
@@ -572,19 +587,22 @@ export class ConversationLocationIndex {
|
||||
}
|
||||
|
||||
private mutableTurnData(turn: number): MutableLocationDataStore {
|
||||
const current = this.turnDataStores.get(turn) ?? this.createDataStore()
|
||||
const current = this.turnDataStores.get(turn) ?? this.createDataStore(turn)
|
||||
this.turnDataStores.set(turn, current)
|
||||
return current
|
||||
}
|
||||
|
||||
private mutableStepData(key: string): MutableLocationDataStore {
|
||||
const current = this.stepDataStores.get(key) ?? this.createDataStore()
|
||||
const current = this.stepDataStores.get(key) ?? this.createDataStore(Number(key.slice(0, key.indexOf(':'))))
|
||||
this.stepDataStores.set(key, current)
|
||||
return current
|
||||
}
|
||||
|
||||
private createDataStore(): MutableLocationDataStore {
|
||||
return new MutableLocationDataStore(store => this.dirtyDataStores.add(store))
|
||||
private createDataStore(turn: number): MutableLocationDataStore {
|
||||
return new MutableLocationDataStore((store) => {
|
||||
this.dirtyDataStores.add(store)
|
||||
this.changedTurns.add(turn)
|
||||
})
|
||||
}
|
||||
|
||||
private storeFor(data: ConversationLocationData): MutableLocationDataStore {
|
||||
|
||||
@@ -3,6 +3,13 @@ export { apply, Config, inject } from './apply.ts'
|
||||
export type { Config as ConversationConfig } from './apply.ts'
|
||||
export { UiConversation } from './conversation/assembly.ts'
|
||||
export type { ConversationBinding } from './conversation/assembly.ts'
|
||||
export type { ConversationGroupRegistry } from './conversation/group-registry.ts'
|
||||
export type {
|
||||
ConversationGroupContext, ConversationGroupData, ConversationGroupDataMap,
|
||||
ConversationGroupDefinition, ConversationGroupedView, ConversationGroupInput,
|
||||
GroupKey, GroupNodePosition, GroupReference, GroupSnapshot, GroupUpdate, NodeChange, NodeKey,
|
||||
NodeReference, RenderEntry,
|
||||
} from './contract/groups.ts'
|
||||
export { ConversationController, UnsupportedImageMediaTypeError } from './service.ts'
|
||||
export type { IConversation } from './service.ts'
|
||||
export type {
|
||||
@@ -40,7 +47,7 @@ export type { ConversationStoreState, ConversationViewRequest, ViewTab } from '.
|
||||
|
||||
export { ConversationNodeAssembler } from './conversation/assembler.ts'
|
||||
export type {
|
||||
ConversationEventDefinitions, ConversationViewDefinitions,
|
||||
ConversationEventDefinitions, ConversationGroupDefinitions, ConversationViewDefinitions,
|
||||
} from './conversation/assembler.ts'
|
||||
export { ConversationDefinitionRegistry } from './conversation/definition-registry.ts'
|
||||
export { ConversationEventRegistry } from './conversation/event-registry.ts'
|
||||
|
||||
@@ -172,11 +172,31 @@ function fallbackDefinition(start: () => string): ConversationNodeDefinition<str
|
||||
}
|
||||
|
||||
describe('ConversationNodeAssembler', () => {
|
||||
it('reports boundary changes only when the owning Turn location changes', () => {
|
||||
const index = new ConversationLocationIndex()
|
||||
const boundaries = [
|
||||
at(SessionSeq(1), 'turn/start', { turn: 1 }),
|
||||
at(SessionSeq(2), 'step/start', { turn: 1, step: 1 }),
|
||||
at(SessionSeq(3), 'step/end', { turn: 1, step: 1 }),
|
||||
at(SessionSeq(4), 'turn/end', { turn: 1 }),
|
||||
]
|
||||
for (const boundary of boundaries) {
|
||||
index.appendBoundary(boundary)
|
||||
expect(index.takeChangedTurns()).toEqual([1])
|
||||
const turn = index.snapshot().turns.get(1)
|
||||
index.appendBoundary(boundary)
|
||||
expect(index.snapshot().turns.get(1)).toBe(turn)
|
||||
expect(index.takeChangedTurns()).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('publishes Location data through stable per-key sources', () => {
|
||||
const index = new ConversationLocationIndex()
|
||||
const turnStart = at(SessionSeq(1), 'turn/start', { turn: 1 })
|
||||
const stepStart = at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })
|
||||
index.rebuild([input(turnStart), input(stepStart)])
|
||||
expect(index.takeChangedTurns()).toEqual([1])
|
||||
expect(index.takeChangedTurns()).toEqual([])
|
||||
const location = index.locationOf(stepStart)
|
||||
if (location.kind !== 'step') throw new Error('scope probe requires a Step Location')
|
||||
const source = location.step.data.source('scope-probe')
|
||||
@@ -192,6 +212,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
}])).toBe(true)
|
||||
expect(source.getSnapshot()).toBe(initial)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
expect(index.takeChangedTurns()).toEqual([1])
|
||||
|
||||
index.publishData()
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
@@ -204,6 +225,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
}])
|
||||
index.publishData()
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
expect(index.takeChangedTurns()).toEqual([1])
|
||||
|
||||
const changed = { value: 2 }
|
||||
index.replaceData([{
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { brandString } from '@deepseek-ai/dsh-brand'
|
||||
import type { ConversationViewNode } from '../src/client/contract/conversation.ts'
|
||||
import type { GroupKey, GroupSnapshot, GroupUpdate, NodeKey, NodeReference, RenderEntry } from '../src/client/contract/groups.ts'
|
||||
import { ConversationGroupStore } from '../src/client/conversation/group-store.ts'
|
||||
|
||||
const key = (value: string): GroupKey => brandString<GroupKey>(value)
|
||||
const node = (value: string, groupPart?: string): NodeReference => ({
|
||||
kind: 'node', key: brandString<NodeKey>(value), ...groupPart === undefined ? {} : { groupPart },
|
||||
})
|
||||
const reference = (value: string): RenderEntry => ({ kind: 'group', key: key(value) })
|
||||
const readNode = (id: NodeKey): ConversationViewNode | undefined => id === 'missing'
|
||||
? undefined
|
||||
: { key: id, kind: 'test', id, target: 'test', data: id }
|
||||
const group = (id: string, members: readonly NodeReference[], data = 0): GroupSnapshot<number> => ({ key: key(id), members, data })
|
||||
|
||||
function replace(store: ConversationGroupStore<number>, entries: readonly RenderEntry[], groups: readonly GroupSnapshot<number>[]) {
|
||||
store.prepareAndInstall({ entries, groups: { kind: 'replace', snapshots: groups } }, readNode)
|
||||
}
|
||||
|
||||
describe('ConversationGroupStore', () => {
|
||||
it('publishes one changed group without changing root or member identities', () => {
|
||||
const store = new ConversationGroupStore<number>()
|
||||
const a = group('a', [node('a')])
|
||||
const b = group('b', [node('b')])
|
||||
replace(store, [reference('a'), node('reply'), reference('b')], [a, b])
|
||||
store.publish()
|
||||
const root = store.entries
|
||||
const source = store.groupSource(key('a'))
|
||||
const other = store.groupSource(key('b'))
|
||||
expect(store.groupSource(key('a'))).toBe(source)
|
||||
const changed = vi.fn()
|
||||
const otherChanged = vi.fn()
|
||||
const unsubscribe = source.subscribe(changed)
|
||||
const unsubscribeOther = other.subscribe(otherChanged)
|
||||
const reads = vi.fn(readNode)
|
||||
store.prepareAndInstall({ groups: { kind: 'apply', upserts: [{ ...a, data: 1 }], removes: [] } }, reads)
|
||||
expect(source.getSnapshot()).toEqual({ ...a, data: 1 })
|
||||
expect(source.getSnapshot()?.members).toBe(a.members)
|
||||
expect(other.getSnapshot()).toBe(b)
|
||||
expect(store.entries).toBe(root)
|
||||
expect(reads).not.toHaveBeenCalled()
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
store.publish()
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
expect(otherChanged).not.toHaveBeenCalled()
|
||||
unsubscribe()
|
||||
unsubscribeOther()
|
||||
store.prepareAndInstall({ groups: { kind: 'apply', upserts: [{ ...a, data: 2 }], removes: [] } }, readNode)
|
||||
store.publish()
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reuses equivalent snapshots and accepts parts in separate positions', () => {
|
||||
const store = new ConversationGroupStore<number>()
|
||||
const reasoning = node('assistant', 'reasoning')
|
||||
const reply = node('assistant', 'response')
|
||||
const original = group('a', [reasoning])
|
||||
replace(store, [reference('a'), reply], [original])
|
||||
const root = store.entries
|
||||
const source = store.groupSource(key('a'))
|
||||
const changed = vi.fn()
|
||||
source.subscribe(changed)
|
||||
store.publish()
|
||||
replace(store, [reference('a'), node('assistant', 'response')], [group('a', [node('assistant', 'reasoning')])])
|
||||
store.publish()
|
||||
expect(store.entries).toBe(root)
|
||||
expect(source.getSnapshot()).toBe(original)
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
store.prepareAndInstall({ groups: { kind: 'apply', upserts: [group('a', [node('assistant', 'reasoning')], 1)], removes: [] } }, readNode)
|
||||
expect(source.getSnapshot()?.members).toBe(original.members)
|
||||
store.publish()
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('moves references atomically and preserves surviving group sources', () => {
|
||||
const store = new ConversationGroupStore<number>()
|
||||
const a = node('a')
|
||||
const b = node('b')
|
||||
replace(store, [reference('old')], [group('old', [a, b])])
|
||||
store.publish()
|
||||
const oldSource = store.groupSource(key('old'))
|
||||
const nextSource = store.groupSource(key('next'))
|
||||
const oldChanged = vi.fn()
|
||||
oldSource.subscribe(oldChanged)
|
||||
store.prepareAndInstall({
|
||||
entries: [a, reference('next')],
|
||||
groups: { kind: 'apply', removes: [key('old')], upserts: [group('next', [b])] },
|
||||
}, readNode)
|
||||
expect(oldSource.getSnapshot()).toBeUndefined()
|
||||
expect(nextSource.getSnapshot()?.members).toEqual([b])
|
||||
expect(oldChanged).not.toHaveBeenCalled()
|
||||
store.publish()
|
||||
expect(oldChanged).toHaveBeenCalledOnce()
|
||||
const root = store.entries
|
||||
store.prepareAndInstall({ groups: { kind: 'apply', removes: [], upserts: [group('next', [node('c')])] } }, readNode)
|
||||
expect(store.entries).toBe(root)
|
||||
expect(store.groupSource(key('next'))).toBe(nextSource)
|
||||
store.prepareAndInstall({ entries: [node('c'), a], groups: { kind: 'apply', removes: [key('next')], upserts: [] } }, readNode)
|
||||
store.publish()
|
||||
expect(store.entries).toEqual([node('c'), a])
|
||||
store.clear()
|
||||
store.publish()
|
||||
expect(store.entries).toEqual([])
|
||||
expect(nextSource.getSnapshot()).toBeUndefined()
|
||||
store.clear()
|
||||
store.publish()
|
||||
})
|
||||
|
||||
it('keeps omitted entries and allows an empty complete replacement', () => {
|
||||
const store = new ConversationGroupStore<number>()
|
||||
replace(store, [node('a'), node('b')], [])
|
||||
const root = store.entries
|
||||
store.prepareAndInstall({ groups: { kind: 'apply', upserts: [], removes: [] } }, readNode)
|
||||
expect(store.entries).toBe(root)
|
||||
store.prepareAndInstall({ entries: [node('b'), node('a')], groups: { kind: 'apply', upserts: [], removes: [] } }, readNode)
|
||||
expect(store.entries).toEqual([node('b'), node('a')])
|
||||
replace(store, [], [])
|
||||
expect(store.entries).toEqual([])
|
||||
})
|
||||
|
||||
it('validates references again on complete replacement, even when arrays are reused', () => {
|
||||
const store = new ConversationGroupStore<number>()
|
||||
const initial = group('a', [node('a')])
|
||||
const entries = [reference('a'), node('b')]
|
||||
replace(store, entries, [initial])
|
||||
expect(() => { store.prepareAndInstall({ entries, groups: { kind: 'replace', snapshots: [initial] } }, () => undefined) })
|
||||
.toThrow('missing Node')
|
||||
expect(store.entries).toBe(entries)
|
||||
expect(store.groupSource(key('a')).getSnapshot()).toBe(initial)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[node('a'), node('a')],
|
||||
[node('a', 'reasoning'), node('a', 'reasoning')],
|
||||
[node('a'), node('a', 'reasoning')],
|
||||
[node('a', 'reasoning'), node('a')],
|
||||
])('rejects overlapping Node positions without changing installed data: %j', (first, second) => {
|
||||
const store = new ConversationGroupStore<number>()
|
||||
replace(store, [node('before')], [])
|
||||
const root = store.entries
|
||||
expect(() => { replace(store, [first, reference('a')], [group('a', [second])]) }).toThrow('overlapping')
|
||||
expect(store.entries).toBe(root)
|
||||
replace(store, [reference('a')], [group('a', [first])])
|
||||
expect(store.groupSource(key('a')).getSnapshot()?.members).toEqual([first])
|
||||
})
|
||||
|
||||
it.each<{ name: string; update: GroupUpdate<number>; error: string }>([
|
||||
{ name: 'duplicate group roots', update: { entries: [reference('a'), reference('a')], groups: { kind: 'apply', upserts: [], removes: [] } }, error: 'duplicate root' },
|
||||
{ name: 'unreferenced group', update: { entries: [reference('a'), reference('c')], groups: { kind: 'apply', upserts: [group('b', [])], removes: [] } }, error: 'no root reference' },
|
||||
{ name: 'missing group', update: { entries: [reference('missing')], groups: { kind: 'apply', upserts: [], removes: [] } }, error: 'missing group' },
|
||||
{ name: 'removed referenced group', update: { groups: { kind: 'apply', upserts: [group('b', [])], removes: [key('a')] } }, error: 'still referenced' },
|
||||
{ name: 'record count mismatch', update: { entries: [], groups: { kind: 'apply', upserts: [], removes: [] } }, error: 'one-to-one' },
|
||||
{ name: 'duplicate upserts', update: { groups: { kind: 'apply', upserts: [group('a', []), group('a', [])], removes: [] } }, error: 'duplicate upserts' },
|
||||
{ name: 'duplicate removals', update: { entries: [], groups: { kind: 'apply', upserts: [], removes: [key('a'), key('a')] } }, error: 'duplicate removals' },
|
||||
{ name: 'absent removal', update: { groups: { kind: 'apply', upserts: [], removes: [key('missing')] } }, error: 'absent' },
|
||||
{ name: 'upsert and remove', update: { groups: { kind: 'apply', upserts: [group('a', [])], removes: [key('a')] } }, error: 'upserted and removed' },
|
||||
{ name: 'missing Node', update: { groups: { kind: 'apply', upserts: [group('a', [node('missing')])], removes: [] } }, error: 'missing Node' },
|
||||
])('rejects $name atomically', ({ update, error }) => {
|
||||
const store = new ConversationGroupStore<number>()
|
||||
const initial = group('a', [node('a')])
|
||||
replace(store, [reference('a')], [initial])
|
||||
store.publish()
|
||||
const root = store.entries
|
||||
const source = store.groupSource(key('a'))
|
||||
expect(() => { store.prepareAndInstall(update, readNode) }).toThrow(error)
|
||||
expect(store.entries).toBe(root)
|
||||
expect(source.getSnapshot()).toBe(initial)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { brandString } from '@deepseek-ai/dsh-brand'
|
||||
import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type {
|
||||
ConversationTimelineSnapshot, ConversationViewBuilder, ConversationViewDefinition, ConversationViewNode,
|
||||
} from '../src/client/contract/conversation.ts'
|
||||
import type {
|
||||
ConversationGroupDefinition, ConversationGroupInput, GroupKey, GroupUpdate, NodeKey,
|
||||
} from '../src/client/contract/groups.ts'
|
||||
import { ConversationNodeAssembler } from '../src/client/conversation/assembler.ts'
|
||||
import { ConversationGroupRegistry } from '../src/client/conversation/group-registry.ts'
|
||||
import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ConversationGroupDataMap {
|
||||
'group-test': number
|
||||
}
|
||||
}
|
||||
|
||||
const groupKey = brandString<GroupKey>('group')
|
||||
const noEvents = { entries: () => [], fallbackEntry: () => undefined }
|
||||
|
||||
class GroupTestBuilder implements ConversationViewBuilder<ConversationViewNode, ReadonlyMap<string, ConversationViewNode>> {
|
||||
readonly empty = new Map<string, ConversationViewNode>()
|
||||
readonly nodes = new Map<string, ConversationViewNode>()
|
||||
readonly publish = vi.fn()
|
||||
private readonly readers = {
|
||||
readNode: (key: NodeKey) => this.nodes.get(key),
|
||||
readTurn: (_turn: number): readonly NodeKey[] => [],
|
||||
readPosition: (key: NodeKey) => {
|
||||
const index = this.input.order.indexOf(key)
|
||||
if (index < 0) return undefined
|
||||
return {
|
||||
turn: undefined,
|
||||
previous: this.input.order[index - 1],
|
||||
next: this.input.order[index + 1],
|
||||
}
|
||||
},
|
||||
}
|
||||
private input: ConversationGroupInput<ConversationViewNode> = {
|
||||
kind: 'replace', order: [], ...this.readers, timeline: { turnOrder: [], turns: new Map() },
|
||||
}
|
||||
|
||||
replace({ nodes, timeline }: { nodes: readonly ConversationViewNode[]; timeline: ConversationTimelineSnapshot }) {
|
||||
this.nodes.clear()
|
||||
for (const node of nodes) this.nodes.set(node.key, node)
|
||||
this.input = { kind: 'replace', order: [...this.nodes.keys()] as NodeKey[], ...this.readers, timeline }
|
||||
return this.nodes
|
||||
}
|
||||
|
||||
apply({ upserts, timeline, changedTurns = [] }: {
|
||||
upserts: readonly ConversationViewNode[]
|
||||
timeline: ConversationTimelineSnapshot
|
||||
changedTurns?: readonly number[]
|
||||
}) {
|
||||
const changes = upserts.map(current => ({ previous: this.nodes.get(current.key), current }))
|
||||
for (const node of upserts) this.nodes.set(node.key, node)
|
||||
this.input = {
|
||||
kind: 'apply', changes, changedTurns, changedTurnOrders: [],
|
||||
order: [...this.nodes.keys()] as NodeKey[], ...this.readers, timeline,
|
||||
}
|
||||
return this.nodes
|
||||
}
|
||||
|
||||
groupInput() { return this.input }
|
||||
}
|
||||
|
||||
function grouping(seen: ConversationGroupInput<ConversationViewNode>[] = []) {
|
||||
return {
|
||||
kind: 'test-group',
|
||||
target: 'group-test' as const,
|
||||
create: (): ConversationGroupInput<ConversationViewNode> | null => null,
|
||||
update: (_context, input) => { seen.push(input); return input },
|
||||
buildGroups: ({ state }) => state === null ? null : {
|
||||
entries: [{ kind: 'group', key: groupKey }],
|
||||
groups: {
|
||||
kind: 'replace',
|
||||
snapshots: [{
|
||||
key: groupKey,
|
||||
data: state.timeline.turns.get(1)?.status === 'closed' ? 1 : 0,
|
||||
members: state.order.map(key => ({ kind: 'node', key })),
|
||||
}],
|
||||
},
|
||||
},
|
||||
} satisfies ConversationGroupDefinition<ConversationViewNode, ConversationGroupInput<ConversationViewNode> | null, number>
|
||||
}
|
||||
|
||||
function start(seq: number, turn = 1): SessionEvent<'turn/start'> {
|
||||
return { seq: SessionSeq(seq), time: seq, type: 'turn/start', data: { turn } }
|
||||
}
|
||||
|
||||
function setup(definition: ConversationGroupDefinition = grouping()) {
|
||||
const builder = new GroupTestBuilder()
|
||||
const create = vi.fn(() => builder)
|
||||
let groupDefinitions = [definition]
|
||||
const views = [{ target: 'group-test', create }]
|
||||
const groups = {
|
||||
entries: () => groupDefinitions,
|
||||
forTarget: (target: string) => groupDefinitions.find(candidate => candidate.target === target),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(noEvents, { entries: () => views }, groups)
|
||||
return {
|
||||
assembler, builder, create, views,
|
||||
removeGroups: () => { groupDefinitions = [] },
|
||||
replaceGroup: (next: ConversationGroupDefinition) => { groupDefinitions = [next] },
|
||||
}
|
||||
}
|
||||
|
||||
describe('group Definition dispatch', () => {
|
||||
it('creates grouping on first activation without eagerly constructing a Builder', () => {
|
||||
const seen: ConversationGroupInput<ConversationViewNode>[] = []
|
||||
const { assembler, builder, create } = setup(grouping(seen))
|
||||
assembler.replaceWindow([{ type: 'event', event: start(1) }], false)
|
||||
expect(assembler.flush()).toBe(false)
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
expect(assembler.grouped('group-test')).toBeUndefined()
|
||||
builder.publish.mockImplementation(() => {
|
||||
expect(assembler.snapshot('group-test')).toBe(builder.nodes)
|
||||
expect(assembler.grouped('group-test')?.groupSource(groupKey).getSnapshot()?.data).toBe(0)
|
||||
})
|
||||
expect(assembler.activateTarget('group-test')).toBe(true)
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
expect(seen.map(input => input.kind)).toEqual(['replace'])
|
||||
expect(builder.publish).toHaveBeenCalledOnce()
|
||||
expect(assembler.activateTarget('group-test')).toBe(false)
|
||||
expect(seen).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('delivers lifecycle changes without Node upserts and installs groups before publication', () => {
|
||||
const seen: ConversationGroupInput<ConversationViewNode>[] = []
|
||||
const { assembler, builder } = setup(grouping(seen))
|
||||
assembler.replaceWindow([{ type: 'event', event: start(1) }], false)
|
||||
assembler.activateTarget('group-test')
|
||||
const grouped = assembler.grouped('group-test')!
|
||||
const entries = grouped.entries
|
||||
const source = grouped.groupSource(groupKey)
|
||||
const changed = vi.fn()
|
||||
source.subscribe(changed)
|
||||
const end: SessionEvent<'turn/end'> = { type: 'turn/end', seq: SessionSeq(2), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }
|
||||
assembler.append({ type: 'event', event: end })
|
||||
builder.publish.mockImplementation(() => { expect(source.getSnapshot()?.data).toBe(1) })
|
||||
assembler.flush()
|
||||
const input = seen.at(-1)!
|
||||
expect(input.kind).toBe('apply')
|
||||
if (input.kind !== 'apply') throw new Error('expected apply')
|
||||
expect(input.changes).toEqual([])
|
||||
expect(input.changedTurns).toEqual([1])
|
||||
expect(grouped.entries).toBe(entries)
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
expect(assembler.flush()).toBe(false)
|
||||
})
|
||||
|
||||
it('rebuilds through the same path and invalidates sources when grouping is removed', () => {
|
||||
const seen: ConversationGroupInput<ConversationViewNode>[] = []
|
||||
const { assembler, removeGroups } = setup(grouping(seen))
|
||||
assembler.activateTarget('group-test')
|
||||
const source = assembler.grouped('group-test')!.groupSource(groupKey)
|
||||
const changed = vi.fn()
|
||||
source.subscribe(changed)
|
||||
assembler.rebuildRegistry()
|
||||
assembler.flush()
|
||||
expect(seen.map(input => input.kind)).toEqual(['replace', 'replace'])
|
||||
expect(assembler.grouped('group-test')!.groupSource(groupKey)).toBe(source)
|
||||
removeGroups()
|
||||
assembler.rebuildRegistry()
|
||||
assembler.flush()
|
||||
expect(source.getSnapshot()).toBeUndefined()
|
||||
expect(assembler.grouped('group-test')).toBeUndefined()
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not ask ungrouped Builders for grouping input', () => {
|
||||
const builder = new GroupTestBuilder()
|
||||
const input = vi.spyOn(builder, 'groupInput')
|
||||
const assembler = new ConversationNodeAssembler(noEvents, { entries: () => [{ target: 'group-test', create: () => builder }] })
|
||||
assembler.activateTarget('group-test')
|
||||
expect(input).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the installed group context until the registry rebuild replaces it', () => {
|
||||
const first = grouping()
|
||||
const firstUpdate = vi.spyOn(first, 'update')
|
||||
const next = grouping()
|
||||
const nextCreate = vi.spyOn(next, 'create')
|
||||
const { assembler, replaceGroup } = setup(first)
|
||||
assembler.activateTarget('group-test')
|
||||
const source = assembler.grouped('group-test')!.groupSource(groupKey)
|
||||
const changed = vi.fn()
|
||||
source.subscribe(changed)
|
||||
replaceGroup(next)
|
||||
assembler.append({ type: 'event', event: start(1) })
|
||||
assembler.flush()
|
||||
expect(firstUpdate).toHaveBeenCalledTimes(2)
|
||||
expect(nextCreate).not.toHaveBeenCalled()
|
||||
assembler.rebuildRegistry()
|
||||
assembler.flush()
|
||||
expect(nextCreate).toHaveBeenCalledOnce()
|
||||
expect(source.getSnapshot()).toBeUndefined()
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
expect(assembler.grouped('group-test')!.groupSource(groupKey)).not.toBe(source)
|
||||
})
|
||||
|
||||
it('keeps published grouping when a later buildGroups returns null', () => {
|
||||
const definition = grouping()
|
||||
const build = vi.spyOn(definition, 'buildGroups')
|
||||
const { assembler } = setup(definition)
|
||||
assembler.activateTarget('group-test')
|
||||
const grouped = assembler.grouped('group-test')!
|
||||
const root = grouped.entries
|
||||
const source = grouped.groupSource(groupKey)
|
||||
const value = source.getSnapshot()
|
||||
const changed = vi.fn()
|
||||
source.subscribe(changed)
|
||||
build.mockReturnValueOnce(null)
|
||||
assembler.append({ type: 'event', event: start(1) })
|
||||
assembler.flush()
|
||||
expect(grouped.entries).toBe(root)
|
||||
expect(source.getSnapshot()).toBe(value)
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a missing Builder input method only after target activation', () => {
|
||||
const create = vi.fn(() => ({ empty: null, replace: () => null, apply: () => null }))
|
||||
const definition = grouping()
|
||||
const assembler = new ConversationNodeAssembler(noEvents, { entries: () => [{ target: 'group-test', create }] }, {
|
||||
entries: () => [definition], forTarget: () => definition,
|
||||
})
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
expect(() => assembler.activateTarget('group-test')).toThrow('requires builder.groupInput()')
|
||||
})
|
||||
|
||||
it('requires complete grouping for replacement input', () => {
|
||||
const definition = grouping()
|
||||
const missing = setup({ ...definition, buildGroups: () => null })
|
||||
expect(() => missing.assembler.activateTarget('group-test')).toThrow('complete grouping for replacement input')
|
||||
const retained = grouping()
|
||||
const build = vi.spyOn(retained, 'buildGroups')
|
||||
const { assembler } = setup(retained)
|
||||
assembler.activateTarget('group-test')
|
||||
build.mockReturnValueOnce(null)
|
||||
assembler.replaceWindow([], false)
|
||||
expect(() => assembler.flush()).toThrow('complete grouping for replacement input')
|
||||
})
|
||||
|
||||
it.each<GroupUpdate<number>>([
|
||||
{ groups: { kind: 'replace', snapshots: [] } },
|
||||
{ entries: [], groups: { kind: 'apply', upserts: [], removes: [] } },
|
||||
])('rejects incomplete grouping for replacement input: %j', (update) => {
|
||||
const { assembler } = setup({ ...grouping(), buildGroups: () => update })
|
||||
expect(() => assembler.activateTarget('group-test')).toThrow('complete grouping for replacement input')
|
||||
})
|
||||
|
||||
it('pauses grouping when its View disappears and rebuilds when the View returns', () => {
|
||||
const seen: ConversationGroupInput<ConversationViewNode>[] = []
|
||||
const definition = grouping(seen)
|
||||
const createGroup = vi.spyOn(definition, 'create')
|
||||
const { assembler, views, create } = setup(definition)
|
||||
assembler.activateTarget('group-test')
|
||||
const source = assembler.grouped('group-test')!.groupSource(groupKey)
|
||||
const changed = vi.fn()
|
||||
source.subscribe(changed)
|
||||
|
||||
const view = views.pop()!
|
||||
assembler.rebuildRegistry()
|
||||
expect(assembler.flush()).toBe(true)
|
||||
expect(assembler.snapshot('group-test')).toBeUndefined()
|
||||
expect(assembler.grouped('group-test')).toBeUndefined()
|
||||
expect(source.getSnapshot()).toBeUndefined()
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
|
||||
assembler.append({ type: 'event', event: start(1) })
|
||||
assembler.flush()
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(createGroup).toHaveBeenCalledOnce()
|
||||
views.push(view)
|
||||
assembler.rebuildRegistry()
|
||||
assembler.flush()
|
||||
expect(create).toHaveBeenCalledTimes(2)
|
||||
expect(createGroup).toHaveBeenCalledTimes(2)
|
||||
expect(seen.map(input => input.kind)).toEqual(['replace', 'replace'])
|
||||
expect(seen.at(-1)?.timeline.turns.has(1)).toBe(true)
|
||||
expect(assembler.grouped('group-test')!.groupSource(groupKey).getSnapshot()?.data).toBe(0)
|
||||
})
|
||||
|
||||
it('registers grouping without creating its Builder and disposes it with its effect', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
const views = new ConversationViewRegistry(ctx)
|
||||
const groups = new ConversationGroupRegistry(ctx, views)
|
||||
const definition = grouping()
|
||||
expect(() => groups.register(definition)).toThrow('is not registered')
|
||||
const create = vi.fn(() => new GroupTestBuilder())
|
||||
views.register({ target: 'group-test', create } satisfies ConversationViewDefinition)
|
||||
const changed = vi.fn()
|
||||
const unsubscribe = groups.subscribe(changed)
|
||||
const dispose = groups.register(definition)
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
expect(groups.forTarget('group-test')).toBe(definition)
|
||||
expect(() => groups.register(definition)).toThrow('already registered')
|
||||
dispose()
|
||||
dispose()
|
||||
expect(groups.forTarget('group-test')).toBeUndefined()
|
||||
expect(changed).toHaveBeenCalledTimes(2)
|
||||
unsubscribe()
|
||||
groups.register(definition)
|
||||
await ctx.fiber.dispose()
|
||||
expect(groups.entries()).toEqual([])
|
||||
expect(changed).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,7 @@ import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
ConversationEventRegistry, ConversationViewRegistry, type ConvViewOwnerProps,
|
||||
ConversationEventRegistry, ConversationViewRegistry, EMPTY_CONVERSATION_SNAPSHOT, type ConvViewOwnerProps,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { en as conversationEn, NS as CONVERSATION_NS, zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
import { apply as applyChat, inject as injectChat } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
@@ -102,14 +102,19 @@ async function bench(snapshot: ChatSnapshot) {
|
||||
runtimes.push(runtime)
|
||||
const ctx = runtime.ctx
|
||||
const chat = createSnapshotStore(snapshot)
|
||||
const conversation = createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT)
|
||||
const events = new ConversationEventRegistry(ctx)
|
||||
const views = new ConversationViewRegistry(ctx)
|
||||
ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never)
|
||||
ctx.provide('uiConversation', {
|
||||
events,
|
||||
views,
|
||||
binding: () => ({ target: () => chat }),
|
||||
binding: () => ({ target: () => chat, snapshot: conversation }),
|
||||
} as never)
|
||||
ctx.uiSession.provide({
|
||||
hooks: ['conversation'],
|
||||
resolve: () => ({ hooks: { conversation } }),
|
||||
})
|
||||
|
||||
await runtime.sessions.add({
|
||||
id: SID,
|
||||
|
||||
@@ -64,7 +64,7 @@ const workspaceState = {
|
||||
error: null,
|
||||
}
|
||||
const conversationState: ConversationState = {
|
||||
views: { get: () => undefined },
|
||||
views: { get: () => undefined, grouped: () => undefined },
|
||||
activeTargets: new Set(),
|
||||
}
|
||||
const emptyKeys: readonly string[] = []
|
||||
|
||||
@@ -62,7 +62,7 @@ const workspaceState = {
|
||||
error: null,
|
||||
}
|
||||
const conversationState: ConversationState = {
|
||||
views: { get: () => undefined },
|
||||
views: { get: () => undefined, grouped: () => undefined },
|
||||
activeTargets: new Set(),
|
||||
}
|
||||
const emptyKeys: readonly string[] = []
|
||||
|
||||
@@ -176,7 +176,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:243',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:248',
|
||||
},
|
||||
{
|
||||
key: 'conversation.chat.commandview',
|
||||
@@ -224,7 +224,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:231',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:236',
|
||||
},
|
||||
{
|
||||
key: 'conversation.chat.node',
|
||||
@@ -241,7 +241,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
},
|
||||
],
|
||||
ownerProps: [
|
||||
'/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}',
|
||||
'/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n /** Renderer-owned Node portion selected by the grouping Definition. */\n groupPart?: string\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}',
|
||||
],
|
||||
ownerPropsReferences: [
|
||||
'MarkdownFileMentions',
|
||||
@@ -293,7 +293,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:212',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:217',
|
||||
},
|
||||
{
|
||||
key: 'conversation.chat.turnTail',
|
||||
@@ -354,7 +354,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:237',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:242',
|
||||
},
|
||||
{
|
||||
key: 'conversation.composer',
|
||||
@@ -1165,7 +1165,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:225',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:230',
|
||||
},
|
||||
{
|
||||
key: 'conversation.plan-review.actions',
|
||||
|
||||
Generated
+6
@@ -825,6 +825,9 @@ importers:
|
||||
'@deepseek-ai/dsh-typert-protocol':
|
||||
specifier: workspace:^
|
||||
version: link:../packages/typert/protocol
|
||||
'@deepseek-ai/dsh-util-values':
|
||||
specifier: workspace:^
|
||||
version: link:../packages/util/values
|
||||
'@xterm/headless':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
@@ -2915,6 +2918,9 @@ importers:
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
'@deepseek-ai/dsh-util-values':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/values
|
||||
'@deepseek-ai/dsh-util-workspace-path':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/workspace-path
|
||||
|
||||
Reference in new issue
Block a user