Merge pull request #4852 from deepseek-harness/feat/chat-process-groups-product
feat(chat): grouped work details and scoped rendering optimizations
This commit is contained in:
commit
a68c1f6539
241 files changed
+5639
-2180
No files matched your search
+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/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
|
||||
2026-09-21-conversation-build-groups.md: 7c02de8dd0aaf842690806ea67ddaf5d4561fc4e
|
||||
2026-09-21-conversation-build-groups.zh.md: 4f2b331596cacdd63d902c9db73931d65ac589ad
|
||||
@@ -12,9 +12,11 @@ A Chat Builder aggregates one target's Nodes and indexes. Hardcoding a Chat proc
|
||||
|
||||
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.
|
||||
|
||||
Broad benchmark budgets do not establish that grouping updates are local. Keyed notifications, component identity, browser layout, and retained memory are distinct properties; passing one check does not establish the others.
|
||||
|
||||
## 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.
|
||||
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. Chat owns its registered segmentation and presentation in the [business-rule reference](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.md). 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
|
||||
|
||||
@@ -22,21 +24,19 @@ The grouping foundation provides a separate, Node-input `ConversationGroupDefini
|
||||
|---|---|
|
||||
| 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. |
|
||||
| PR #4565 supplies the product reference | The Chat business-rule reference owns the current behavior and its agreed adjustments. |
|
||||
| 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. |
|
||||
| Business remains target-owned | Chat owns process grouping, summaries, notifications, and footer behavior; generic assembly does not interpret them. |
|
||||
|
||||
### 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.
|
||||
Grouping preserves the independent Node-assembly decisions. Cross-View navigation and `toolCallFocus` remain separate responsibilities; Group references carry no View handles or resource-navigation policy.
|
||||
|
||||
## Definition input, state, and registration
|
||||
|
||||
@@ -116,7 +116,9 @@ The root selects `views.grouped('chat')?.entries` through the existing useConver
|
||||
|
||||
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.
|
||||
The stable Group parent is a measurable `div`; its body owns capped inner scrolling and its content box reports growth. CSS inheritance remains available, and business styles adapt child/sibling selectors. 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.
|
||||
|
||||
The presentation channel maps each stored mode to a stable policy object. Seats and renderers select the fields they consume instead of receiving a mode prop through every renderer. Mode changes do not register Definitions or replay Contexts. Whole-Turn eligibility uses that Turn's loaded lifecycle facts rather than Session-wide pagination completion; the business-rule reference defines partial-history folding. Local group disclosure remains component state, while explicit whole-Turn closing resets participating disclosures through the injected Hook without replacing keys.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -138,6 +140,12 @@ The stable Group parent is a `div` with `display: contents`, without its own lay
|
||||
|
||||
**Remove the group wrapper in Expanded.** Rejected: changing the parent remounts members even when their keys survive.
|
||||
|
||||
**Attach a header to the first member or to a per-Step Context.** A first-member header couples group UI to an arbitrary business row. Per-Step identity cannot distinguish multiple groups in one Step; sharing aggregate Turn data also refreshes unrelated headers or leaves later Step reads stale.
|
||||
|
||||
**Emit multiple Nodes from one event Context or derive child Contexts.** Most event Contexts own one Node; arrays add indirection without solving cross-Node grouping. Parent-derived Contexts require forwarding, replay, and removal lifecycles. A separate framework decision is warranted only if derived entities need those lifecycles independently of grouping.
|
||||
|
||||
**Keep all Markdown mounted or reset renderers by remounting.** Retaining lightweight seats is not a reason to retain every full Markdown body. Full bodies remain disclosure-owned, and explicit resets avoid destroying unrelated renderer state. Importing all of #4565 at once would also mix independent visual changes with grouping costs and obscure which behavior caused a regression.
|
||||
|
||||
## 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.
|
||||
@@ -145,6 +153,15 @@ The stable Group parent is a `div` with `display: contents`, without its own lay
|
||||
- [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.
|
||||
|
||||
Business behavior is verified through keyed-update regressions and recorded Web replay.
|
||||
|
||||
| Evidence | Required observation |
|
||||
|---|---|
|
||||
| Keyed notifications and React updates | Content growth affects the owning Node and affected Group; unchanged historical rows and unrelated Turns receive no extra notifications. Mode changes preserve keys and member parents. |
|
||||
| Recorded Web replay | Current accessible titles, group disclosure, hidden ordinary Context, trigger notices, and footer placement match the committed expected output. |
|
||||
|
||||
[Recorded Web scenarios](../../../../apps/web/tests/steering.e2e.ts) cover online steering, reconnect handoff, and grouped presentation through the shipped Web profile. These behavior checks do not establish a quantified latency or memory improvement.
|
||||
|
||||
## Consequences
|
||||
|
||||
- This is an explicit framework extension: a new input protocol, registry, context, publication phase, and reader. It is not merely another callback.
|
||||
@@ -152,4 +169,5 @@ The stable Group parent is a `div` with `display: contents`, without its own lay
|
||||
- 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.
|
||||
- Revealing hidden parts, selection/copy, interruption notices, group disclosure, and outer Turn interaction belong to Chat's business adaptation; generic reference tests alone do not establish those behaviors.
|
||||
- Turn status/duration replaces the old tool/message-count title, so status, accessible names, and empty-process Turns require explicit presentation coverage. Hiding ordinary Context retains non-human waking notices and original Session/Trajectory inspection; footer placement depends on the real Turn end rather than group membership.
|
||||
@@ -12,9 +12,11 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat
|
||||
|
||||
过程组与 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 细节。
|
||||
分组基础机制提供独立的节点输入 `ConversationGroupDefinition`、注册表及 Session 内上下文、Builder 通用输入与发布、按键 Group 存储及两种 React 分支。Chat 在[业务规则参考](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.zh.md)中拥有其已注册的分段与展示规则。[子系统参考](../../../../docs/subsystems/conversation.zh.md#group-definitions)与[源码类型](../../../../packages/client/ui-conversation/src/client/contract/groups.ts)拥有当前 API 细节。
|
||||
|
||||
### 职责
|
||||
|
||||
@@ -22,21 +24,19 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat
|
||||
|---|---|
|
||||
| 分组业务属于已注册 Definition | 成员、分段、回复、steering、重试、摘要和缓存失效放在一起。 |
|
||||
| Builder 与 assembler 做统一工作 | 提供输入、调度 Definition、校验引用、复用身份并发布,不创建具体分组类。 |
|
||||
| 产品行为以 PR #4565 为准 | 保留行为,不复制其侵入性实现。 |
|
||||
| PR #4565 提供产品参考 | Chat 业务规则参考拥有当前行为及已确认的调整。 |
|
||||
| React 根循环只有两种 kind | 根顺序包含 NodeReference 和 GroupReference,不引入另一套布局对象模型。 |
|
||||
| 所有模式共用分组 | 模式不进入 Definition 输入、key 或父级选择。 |
|
||||
| 不采用 buildLayout | ConversationViewDefinition 保留现有职责。 |
|
||||
| 先基础、后业务 | 基础 PR 不包含真实 Chat 分组、摘要、通知或页脚变化。 |
|
||||
| 业务属于目标包 | 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 或资源导航架构。
|
||||
分组保留独立的节点组装决策。跨 View 导航与 `toolCallFocus` 仍是独立职责,Group 引用不携带 View 句柄或资源导航策略。
|
||||
|
||||
## Definition 输入、状态与注册
|
||||
|
||||
@@ -116,7 +116,9 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价
|
||||
|
||||
React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派生,不另设 RenderKey 类型或 renderer 分发字段。Compact、Detailed、Expanded 保留同一组容器及成员父级,展示变化不重新运行 Group Definition,也不选择不同的根分支。
|
||||
|
||||
稳定 Group 父级使用 `div` 与 `display: contents`,自身不产生布局盒子。CSS 继承仍然可用,业务样式适配子级/兄弟选择器并拥有可测量的正文容器。CSS 变量和几何信息都不进入 Definition。阅读位置采样测量成员 Node,保留部分专属锚点;既有轮次导航将原 NodeKey 解析到它的第一个可见部分。
|
||||
稳定 Group 父级使用可测量的 `div`,正文拥有组内限高滚动,内容盒子报告尺寸增长。CSS 继承仍然可用,业务样式适配子级/兄弟选择器。CSS 变量和几何信息都不进入 Definition。阅读位置采样测量成员 Node,保留部分专属锚点;既有轮次导航将原 NodeKey 解析到它的第一个可见部分。
|
||||
|
||||
展示通道将每个存储模式映射为稳定策略对象。Seat 和 renderer 选择自己使用的字段,不通过所有 renderer 透传模式 prop。模式变化不注册 Definition,也不重放 Context。整轮折叠资格使用本轮已加载的生命周期事实,不依赖整个 Session 的分页完成状态;半截历史的折叠规则由业务参考定义。组开合保留在组件本地状态中,显式收起整轮通过注入 Hook 重置参与折叠的内部开合,不更换 key。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -138,6 +140,12 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派
|
||||
|
||||
**Expanded 移除组容器。** 不采用,即使 key 不变,父级改变仍会重挂载成员。
|
||||
|
||||
**让首成员或每 Step 的 Context 承载组头。** 首成员组头把组 UI 耦合到任意业务行。每 Step 的身份无法区分同一步内的多个组,共享整轮聚合数据还会刷新无关组头,或让后续 Step 的读取过期。
|
||||
|
||||
**一个事件 Context 输出多个 Node,或派生子 Context。** 多数事件 Context 只拥有一个 Node,数组增加间接层却不能解决跨 Node 分组。父级派生 Context 需要转发、重放及移除生命周期,只有派生实体需要独立于分组的这些生命周期时,才值得另作框架决策。
|
||||
|
||||
**常驻全部 Markdown,或靠重挂载重置 renderer。** 保留轻量 Seat 不意味着需要常驻所有完整 Markdown 正文。完整正文仍由开合状态控制,显式重置避免销毁无关 renderer 状态。一次性引入 #4565 的全部改动还会混合独立视觉变化与分组成本,难以判断导致回退的具体行为。
|
||||
|
||||
## Verification
|
||||
|
||||
- [Group 存储测试](../../../../packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts)覆盖原子引用校验、根与成员身份复用、局部发布,以及删除组但保留原 Node。
|
||||
@@ -145,6 +153,15 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派
|
||||
- [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)覆盖组内阅读锚点、历史前插及部分感知的轮次导航。
|
||||
|
||||
业务行为通过按键更新回归与已录制的 Web 回放验证。
|
||||
|
||||
| 证据 | 必须观察到的结果 |
|
||||
|---|---|
|
||||
| 按键通知与 React 更新 | 正文增长影响所属 Node 和相关 Group;未变化历史行及其他 Turn 没有额外通知。模式变化保留 key 与成员父级。 |
|
||||
| 已录制的 Web 回放 | 当前可访问标题、组开合、普通 Context 隐藏、触发通知及页脚位置与提交的预期输出一致。 |
|
||||
|
||||
[已录制的 Web 场景](../../../../apps/web/tests/steering.e2e.ts)通过正式 Web profile 覆盖在线 steering、重连接续与分组展示。这些行为检查不构成量化延迟或内存改善的证明。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 这是明确的框架扩展,包含新输入协议、注册表、上下文、发布阶段及读取器,不只是增加一个回调。
|
||||
@@ -152,4 +169,5 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派
|
||||
- Node 顺序及可见性只有 Builder 一个所有者,分组不独立排序原始事件,也不在通用层再次推断成员。
|
||||
- 稳定挂载不消除布局、绘制或保留内存的成本,不宣称实测延迟或最优性能。
|
||||
- 真实组拆并、首成员变化及分页修正可以改变身份,模式切换保证不禁止这些正常变化。
|
||||
- 隐藏部分的揭示、选择复制、中断提示、组开合与外层 Turn 联动需要业务适配。基础测试使用混合引用,不启用真实分组。
|
||||
- 隐藏部分的揭示、选择复制、中断提示、组开合与外层 Turn 联动属于 Chat 业务适配,仅凭通用引用测试不能确认这些行为。
|
||||
- 整轮状态与用时替代旧工具/消息计数标题,因此状态、可访问名称和空过程轮次需要明确的展示覆盖。隐藏普通 Context 时保留非人工唤醒通知及原始 Session/Trajectory 查看能力;页脚定位依赖真实 Turn 结束,不依赖分组成员。
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
# 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/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md
|
||||
2026-09-20-chat-presentation-policy-and-step-groups.md: 65fbb313d57a34a792ed2ec08d0adfb2fdd5f03d
|
||||
2026-09-20-chat-presentation-policy-and-step-groups.zh.md: 7ac3442476ea34d5127376cfc40d42892403ce47
|
||||
-288
@@ -1,288 +0,0 @@
|
||||
# Agent Note: Chat presentation policy channel and process-group architecture
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-09-20-chat-presentation-policy-and-step-groups.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
At the start of this proposal, Chat's display setting had two modes: `normal` did not fold content, while `compact` folded process rows behind a whole-Turn control after completion. `TranscriptViewPolicy.mode` was injected into ChatView as an observable. ChatView read it through `useTranscriptView`, converted it to the boolean `compactTranscript`, and passed that prop through `ChatNodeList` to every `ChatNodeSeat`. The global `hasMore` boolean followed the same path as `historyIncomplete`. Either change re-rendered every seat, even when only a few foldable rows changed their output.
|
||||
|
||||
The product requires three modes—Compact, Detailed, and Expanded—with consecutive process rows between replies collected into collapsible groups in Compact and Detailed. PR #4565 implemented the full feature, but was reverted eight minutes after merging because of performance regressions. The recorded local before/after comparison found long-session pagination +27%, DOM nodes +23%, heap +14%, and Trajectory switching +10%, all within CI budgets. None of the six existing repository benchmarks covered the new hot path: reasoning or tool chunks entering an active group during streaming, group reprojection, and seat re-rendering.
|
||||
|
||||
The review identified implementation choices, not the product requirements, as the problem:
|
||||
|
||||
- The mode became an input to list structure. `ChatNodeList` subscribed to the entire `layout` and generated wrapper components from ranges; Expanded removed those wrappers. Switching modes remounted the list, and regrouping rebuilt wrapper keys.
|
||||
- The mode entered `ChatNodeOwnerProps`. Every seat's owner object changed with the mode, re-rendering all business renderers, including unrelated user messages, tool rows, and compaction markers.
|
||||
- A seat split one Assistant node into derived reasoning and response objects solely to wrap the former in the DOM. Master's `AssistantMarkdown` already had `reasoningHidden` and `revealProcess`, which could hide reasoning independently.
|
||||
- Consumers repeated infrastructure-row filtering in four places, although nodes already had a Definition-owned `visibility` field.
|
||||
|
||||
The deeper difficulty is information ownership: a group header must exist before its members as soon as the group opens, but its content requires a view of the entire Turn. Each event-driven Context receives only its own events. Only a turn-level Context can observe the whole Turn, and it can produce only one node.
|
||||
|
||||
This proposal supplies an architecture for three modes and grouping that satisfies the constraints below and confines later product changes to a small set of nodes.
|
||||
|
||||
The three settings, presentation policy channel, and per-Turn folding eligibility are implemented; step-group remains proposed. The [migration progress table](../feature/2026-09-20-chat-work-details-migration.md) owns current implementation, committed scope, and incomplete items. The grouping state machine and change inventory below do not describe delivered work.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|
|
||||
| 1 | Definitions own group structure | A Definition matching multiple events in the Turn folds group opening, closing, member ranges, and summaries; renderers do not derive them by scanning nodes |
|
||||
| 2 | Limit seat re-rendering | Mode changes re-render only seats whose presentation changes; streaming chunks re-render only their seat and group header; user messages, replies, and turn-tail do not re-render for either change |
|
||||
| 3 | One node per Context | Do not introduce multiple nodes per Context; most Contexts have one element, and arrays would fragment memory further |
|
||||
| 4 | Modes do not toggle Definitions | Registry changes rebuild and replay all Contexts, refreshing every seat; Definitions must be mode-independent |
|
||||
| 5 | Decide folding per Turn | A Turn can fold when its own `turn/start` is loaded; the oldest partial Turn stays open and is not optimized here |
|
||||
| 6 | ui-conversation may change | Engine changes are allowed only where needed for this design to work correctly |
|
||||
| 7 | Initial implementation scope | Initially change only code and this note; add tests and other documentation during CI repair |
|
||||
|
||||
Accepting a full-page browser layout on mode changes does not mean accepting every seat re-rendering. Layout is a geometric result; a seat re-render executes a React component function. This proposal constrains the latter.
|
||||
|
||||
## Viable approaches
|
||||
|
||||
There are five approaches to group-header identity and content ownership.
|
||||
|
||||
**Approach A: the first member's seat renders the header.** Keep the list unchanged. The builder derives membership and tells the first member's seat to render a title above itself; other member seats use the existing `hidden` mechanism. This adds no new concept, but creates a third seat state—visible with only a title—and attaches the header to a business node's seat.
|
||||
|
||||
**Approach α: one Context per step supplies a header.** Add a Definition identified by `turn:step`. During construction, read the preceding `assistant-step` data to decide whether this is a new group start and emit a header node if so. Another turn-level Definition publishes the group's content as Turn data for the renderer.
|
||||
|
||||
**Approach β: one Context emits multiple nodes.** A turn-level Definition emits the whole-Turn control and N group headers.
|
||||
|
||||
**Approach γ: a parent Context derives child Contexts.** When a group opens, the turn-level fold creates a `step-group` child Context and forwards later events to it. Each child owns one node. The engine needs forwarding and cascaded replay primitives.
|
||||
|
||||
**Approach δ: Definitions own structure; the builder materializes rows.** A turn-level Definition folds the group list and publishes Turn data. The Chat builder materializes header nodes and assigns membership. Seats use combined selectors for visibility. `ViewBuilder.apply` receives the Location data whose references changed in the flush.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**One Context per group cannot satisfy the matching rules.** The engine requires `match(event)` to derive an id from one event. A group number depends on whether a reply preceded the event; one event cannot determine it. This is incompatible with the matching contract, not merely difficult to implement.
|
||||
|
||||
**Approach α conflicts with decision 2.** Header identity is derivable, but every header reads the same Turn data, so one group's change refreshes all headers in that Turn. Reading later steps through `turn.steps` instead leaves stale references because a header node is not rebuilt when later steps arrive.
|
||||
|
||||
**Decision 3 rules out approach β.** See the table above.
|
||||
|
||||
**Approach γ is not justified yet.** It has the clearest ownership, but forwarding and cascaded replay would add hundreds of lines to the assembler core. It remains an alternative under the conditions at the end of this note.
|
||||
|
||||
**Approach A remains the fallback.** If builder-owned materialization is rejected in practice, use A. Both share the same structural source and differ only in what carries the header row.
|
||||
|
||||
Choose approach δ.
|
||||
|
||||
## Proposal
|
||||
|
||||
### Data flow
|
||||
|
||||
```
|
||||
事件流
|
||||
──▶ step-groups Definition(turn 级 fold,多重匹配本轮事件)
|
||||
──▶ Turn 数据 step-groups:组列表,每组一个对象
|
||||
──▶ Chat builder
|
||||
├─ 物化组头节点:一组一个 key,data 即组对象
|
||||
└─ 成员归属:按 step 映射到 key,写进 per-key presentation
|
||||
──▶ seat:hidden = f(策略字段, 角色, 展开状态)
|
||||
──▶ 组头渲染器:读自己节点的 data
|
||||
展示策略
|
||||
──▶ 渲染器:按 kind 注入的 hooks,选单个字段
|
||||
──▶ seat:组合 selector,选出的是自己的结论
|
||||
```
|
||||
|
||||
The only engine change passes the Location data whose references changed during a flush to the builder through `apply`.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
| Layer | Owns | Explicitly does not own |
|
||||
|---|---|---|
|
||||
| `step-groups` Definition | Opening and closing groups, `headStep`, `closeStep`, tool counts and names, running tools; publishes Turn data | View nodes or identification of member nodes |
|
||||
| Chat builder | Header-node identity and lifecycle, membership, `turnStarted`, identity comparison and publication of per-key presentation | Reading events or deriving business structure |
|
||||
| Presentation policy object | Mapping the enum to capability fields | Node or seat state |
|
||||
| Seat | Combining policy fields, role, and store state into hidden state; supplying group open/close actions to the header renderer | Determining group boundaries |
|
||||
| Header renderer | Title, chevron, click-to-toggle interaction | The source of group content |
|
||||
| assistant-step renderer | Settled reasoning first-line preview; hiding reasoning when closing a group | Group boundaries |
|
||||
|
||||
### Header-node identity and lifecycle
|
||||
|
||||
A header node's key is `step-group:${turn}:${headStep}`. `headStep` is fixed when a group opens, and later events cannot change its first member. The header exists and is reused from that point onward; its key does not change during streaming. Closing the group only changes `closed` in its data from false to true.
|
||||
|
||||
The header anchor is the first member's anchor minus a synthetic offset, placing it immediately before that member. A streaming Assistant is anchored at its first visible chunk, then at its message event after persistence. Its anchor moves once; the header follows in the same structural update without adding another update.
|
||||
|
||||
A header is part of the whole-Turn process and hides with its members when the Turn folds. It is not in `TURN_PROCESS_INDEPENDENT_KINDS`.
|
||||
|
||||
Header data is the group object published by the fold. Closed group objects never change references, so their header sources never notify. Each event that changes a running group replaces only that group's object and re-renders only its header.
|
||||
|
||||
### Fold state machine
|
||||
|
||||
The `step-groups` Definition uses the Turn number as its id, starts on `turn/start`, and matches the same event set as `turn-process`. Its State is `{ turn, groups, open }`: `groups` is an array of group objects, and `open` is the active group's index or empty.
|
||||
|
||||
| Event | Transition |
|
||||
|---|---|
|
||||
| `tool/call` | If no group is open, open one at the event's step; increment the count, record distinct tool names, and add callId to the running set |
|
||||
| Appended `tool/result` | Remove callId from the running set |
|
||||
| `assistant/live-chunk` reasoning delta with visible characters | Open a group at the chunk's step if none is open |
|
||||
| `assistant/live-chunk` text delta or text-block end with visible characters | Close an open group at the chunk's step |
|
||||
| Appended `assistant/message` | Apply the reasoning rule for visible reasoning and the text rule for a visible reply, in reasoning-then-text order |
|
||||
| `turn/end` | Mark an open group as closed by the Turn end |
|
||||
| `step/start`, `step/end`, `llm/retry` | No state change; retry rows belong to no group |
|
||||
|
||||
Three invariants apply:
|
||||
|
||||
- Idempotence: opening an already-open group and closing an already-closed group do nothing. Live chunks and persisted messages describing the same content do not open or close twice; replay with only persisted messages derives the same group list.
|
||||
- Stable references: `update` returns the original object when State is unchanged; the `groups` array changes reference only when the group list changes; closed group objects are never replaced.
|
||||
- Forward-only folding: do not read neighboring Contexts, nodes, or later history.
|
||||
|
||||
Turn-data publication: `buildLocationData` returns a value only for the turn phase and reuses its previous value when the `groups` reference is unchanged, preventing republication. A history window without `turn/start` uses the same fallback as `turn-process`: fold State from the available matches.
|
||||
|
||||
Publication timing: batch live chunks at animation frames; publish other changes immediately.
|
||||
|
||||
### Membership rules
|
||||
|
||||
The builder assigns membership by step, not by seq ranges, because live-chunk seqs and persisted-event seqs use different scales.
|
||||
|
||||
| Node | Rule |
|
||||
|---|---|
|
||||
| `tool-call` at step s | Belongs to the last group with `headStep ≤ s`. If text closes the preceding group in the same step, later tool calls open a new group at that step, so the last group is the applicable one |
|
||||
| `assistant-step` at step s where a group has `closeStep === s` | Closer role: the node remains visible; folding the group hides only its reasoning through the existing `reasoningHidden` mechanism |
|
||||
| Other `assistant-step` at step s | Member role: belongs to the group with `headStep ≤ s` whose `closeStep` has not been passed |
|
||||
| `model-retry`, `turn-error`, `turn-max-tokens`, `turn-tail`, `user`, `steering` | Belong to no group |
|
||||
|
||||
Recompute membership only when the group list changes, with cost proportional to that Turn's node count. Streaming chunks that leave the group list unchanged do not publish member presentation.
|
||||
|
||||
The header anchors before the earliest member. If a group contains only its closer's reasoning and no members, such as reasoning followed by a reply in one step, anchor the header before the closer so the hidden reasoning still has a control that reveals it.
|
||||
|
||||
### Delivering group changes to the builder
|
||||
|
||||
The fold publishes Turn data, not view nodes. When Location data changes, the engine's `flush` sets `timelineDirty` and calls `apply` on all active targets, but the builder cannot identify the changed Turn and key. Inferring them from an unrelated node update in the same Turn and flush is not a reliable guarantee.
|
||||
|
||||
Add optional `changedLocationData` to `ConversationViewBuilder.apply`: the Location values whose references changed in this flush, each carrying `kind`, `turn`, optional `step`, and `key`. The assembler already compares references in `applyDirtyLocationData`; it collects those results for the builder. Chat reprojects only Turns with `key === 'step-groups'`. When the optional input is absent, the builder falls back to Turns affected by the current upserts.
|
||||
|
||||
### Combining hidden state in a seat
|
||||
|
||||
Seats stop receiving `compactTranscript` and `historyIncomplete`. A seat reads policy through `usePresentation`, selecting its own result rather than the mode:
|
||||
|
||||
```ts
|
||||
declare function usePresentation<T>(select: (policy: {
|
||||
stepGrouping: 'collapsed' | 'none'
|
||||
foldCompletedTurns: boolean
|
||||
}) => T): T
|
||||
declare const group: { readonly headStep: number } | undefined
|
||||
|
||||
const grouping = usePresentation(p => group === undefined ? 'none' : p.stepGrouping)
|
||||
const foldCompleted = usePresentation(p => p.foldCompletedTurns)
|
||||
```
|
||||
|
||||
For a seat outside every group, `grouping` is `'none'` in all modes, so it does not re-render. `foldCompletedTurns` likewise stays true in all modes. The selector hook uses `useSyncExternalStoreWithSelector` and re-renders only when the selected value changes.
|
||||
|
||||
Three sources combine into `hidden`:
|
||||
|
||||
| Source | Condition | Reveal action |
|
||||
|---|---|---|
|
||||
| Whole-Turn folding | Existing rule: `turnClosed && turnStarted && has reply`, without manual expansion | Open the Turn |
|
||||
| Group folding | `grouping === 'collapsed'`, member role, group not manually expanded | Open the group |
|
||||
| Group header in Expanded | Header role and `grouping === 'none'` | None |
|
||||
|
||||
The closer's reasoning is hidden independently: the seat passes `stepGroup.reasoningHidden` through owner props to the assistant-step renderer.
|
||||
|
||||
The chat store keeps group expansion under a key combining `turn` and `headStep`, alongside whole-Turn manual expansion.
|
||||
|
||||
### Re-render scope
|
||||
|
||||
The following guarantees also define the next round of render-count assertions. Each row names an exact set, not an upper bound.
|
||||
|
||||
| External change | May re-render |
|
||||
|---|---|
|
||||
| A running group receives a reasoning chunk | That assistant seat |
|
||||
| A running group receives `tool/call` | That tool seat and the group's header |
|
||||
| A reply appears and closes a group | That assistant seat and group header; membership recomputation occurs only when opening or closing groups |
|
||||
| A new group opens | Insert the new header into `order` with one structural layout; no other seat refreshes |
|
||||
| Switch Compact ↔ Detailed | Only assistant-step renderers with settled reasoning |
|
||||
| Switch to or from Expanded | Only member and header seats |
|
||||
| Change `performanceUsage` | Only turn-tail |
|
||||
| Load an earlier page | New seats and historical seats whose presentation changes |
|
||||
| Change locale | All seats, as a control case |
|
||||
|
||||
### Presentation policy object
|
||||
|
||||
Separate the persisted enum from runtime capabilities. `TranscriptViewMode` contains `compact`, `detailed`, and `expanded`; reading the legacy saved value `normal` maps it to `detailed` without writing it back. Runtime capabilities are the derived `ChatPresentationPolicy`:
|
||||
|
||||
| Field | compact | detailed | expanded |
|
||||
|---|---|---|---|
|
||||
| `foldCompletedTurns` | true | true | true |
|
||||
| `stepGrouping` | collapsed | collapsed | none |
|
||||
| `settledReasoningPreview` | false | true | true |
|
||||
|
||||
The mapping has one owner. Each mode maps to a constant object with a stable reference, so the derived observable needs no subscription bookkeeping: `getSnapshot` performs a lookup and `subscribe` forwards to the mode observable.
|
||||
|
||||
Renderers select needed fields instead of comparing enum values. Policy is injected by kind: the assistant-step registration receives it through `inject.hooks.presentation`, just as turn-tail receives `performanceUsage`. Adding a fourth mode changes the table, not the renderers.
|
||||
|
||||
### Per-Turn folding
|
||||
|
||||
Add `turnStarted` to per-key presentation, derived from whether the Turn's `turn/start` is in the loaded window. Folding eligibility uses it instead of global `!historyIncomplete`. Completing pagination changes no global seat prop; only new nodes and presentation in their Turns change.
|
||||
|
||||
### Complexity requirements
|
||||
|
||||
| Scenario | Fold | Builder | Publication |
|
||||
|---|---|---|---|
|
||||
| Content chunk without a group-boundary change | O(1) transition, original object returned | No reprojection | None |
|
||||
| Event that changes a group boundary | O(1) | Reproject only the owning Turn, proportional to its node count | Keys whose facts changed in that Turn |
|
||||
| Structural changes and pagination | Proportional to matched events | One full pass | Keys whose facts changed |
|
||||
| Mode change | None | None | None; seat and renderer selectors determine re-renders |
|
||||
|
||||
## Change inventory
|
||||
|
||||
| Package | File | Change |
|
||||
|---|---|---|
|
||||
| ui-conversation | `contract/conversation.ts` | Add `ConversationLocationDataUpdate` and optional `changedLocationData` to `ConversationViewBuilder.apply` |
|
||||
| ui-conversation | `conversation/assembler.ts` | Collect changed Location references in `applyDirtyLocationData` and pass them through `apply` |
|
||||
| ui-conversation | `index.ts` | Export the new type |
|
||||
| ui-chat | `chat-settings.ts` | Three-mode enum; schema accepts legacy `normal` |
|
||||
| ui-chat | `transcript-view.ts` | Map `normal` to `detailed` on read |
|
||||
| ui-chat | New `presentation-policy.ts` | Policy objects, mapping table, derived observable |
|
||||
| ui-chat | `settings/TranscriptViewRow.tsx` | Three options |
|
||||
| ui-chat | `locale.ts` | Mode labels and group-header copy |
|
||||
| ui-chat | `contract/slots.ts` | `ChatViewInjected.hooks.presentation`, `PresentationInjected`, `ChatNodeOwnerProps.stepGroup`, `StepGroupOwnerProps` |
|
||||
| ui-chat | `contract/snapshot.ts` | `ChatTurnProcessPresentation.turnStarted`, `ChatNodeGroupPresentation`; combine `processSource` values per key |
|
||||
| ui-chat | `contract/chat-nodes.ts` | `StepGroupChatData` |
|
||||
| ui-chat | `contract/store.ts`, `stores.ts` | Manual group-expansion state and actions |
|
||||
| ui-chat | `conversation-nodes/common.ts` | Header anchor offset |
|
||||
| ui-chat | New `conversation-nodes/step-groups.ts` | Fold Definition and Turn-data declaration merging |
|
||||
| ui-chat | New `conversation-nodes/step-group-nodes.ts` | Materialize headers and project membership |
|
||||
| ui-chat | `conversation-nodes/turn-process-presentation.ts` | `turnStarted` and combined per-key presentation |
|
||||
| ui-chat | `conversation-nodes/chat-snapshot-builder.ts` | Integrate materialization, consume `changedLocationData`, support key deletion in the store |
|
||||
| ui-chat | `conversation-nodes/register.ts` | Register the fold Definition |
|
||||
| ui-chat | `chat/ChatView.tsx` | Stop passing `compactTranscript` and `historyIncomplete`; pass `usePresentation` |
|
||||
| ui-chat | `chat/ChatNodeSeat.tsx` | Combined selector, three hiding sources, `stepGroup` owner props |
|
||||
| ui-chat | `chat/AssistantNodeView.tsx`, `chat/AssistantMarkdown.tsx`, `chat/ReasoningRow.tsx` | Inject policy, toggle settled reasoning previews, hide closer reasoning |
|
||||
| ui-chat | New `chat/StepGroupNodeView.tsx` | Header renderer |
|
||||
| ui-chat | `chat/register-node-renderers.ts` | Register header renderer and inject policy into assistant-step |
|
||||
| ui-chat | `apply.ts`, `index.ts` | Create and inject policy observable; export types |
|
||||
|
||||
This inventory retains the complete grouping proposal. Independent features have updated READMEs, i18n pairing files, and the slot catalog. The migration record tracks tests and snapshots; those updates do not complete the outstanding grouping work.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Grouping validation remains unimplemented; the migration record tracks independent presentation-feature evidence.
|
||||
|
||||
**Render-count isolation.** Add counters to the existing `chat-view.client.spec` mounting approach: React Profiler `onRender` counts per seat, and test renderers registered in `conversation.chat.node` count by kind. Assert the exact sets in the re-render-scope table for each stimulus.
|
||||
|
||||
**Publication counts.** Count notifications from each key's source under the same stimuli and assert the exact set.
|
||||
|
||||
**Fold state-machine tests.** Feed event sequences and assert opening, closing, a new group after text then tools in one step, Turn-end closure, idempotence, unchanged closed-group references, and equivalent replay using only persisted messages.
|
||||
|
||||
**Membership tests.** Given the group list and Turn nodes, assert each key's role.
|
||||
|
||||
**Benchmark coverage.** Add incremental apply with reasoning and tools to `conversation-fold`, and a streaming fixture with tools and reasoning to `long-session-browser`. These provide wall-clock evidence, not a gate.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Builder-owned node creation.** This is the first proposed node creation by a view builder. A builder may only materialize rows from Turn or Step data; it must not read events or derive business structure. Synthetic keys use `step-group:`, while Context keys begin with digits, preventing collisions. Fall back to approach A if this ownership is rejected in practice.
|
||||
- **Header anchor moves once with its first member.** Persisting a streaming Assistant moves its anchor from the first visible chunk to the message event. The header follows in the Assistant's structural update.
|
||||
- **Actual mode differences in this phase.** Compact and Detailed currently differ only in settled reasoning first-line previews. Running detail titles and category summaries are outside this phase; the policy table leaves room for them.
|
||||
- **Pagination and partial Turns.** The oldest partial Turn neither folds nor groups. Membership uses steps and is independent of pagination.
|
||||
- **Header copy.** This phase proposes only processing/completed labels and tool-call counts; category summaries remain product work.
|
||||
- **Streaming chunk step.** `assistant/live-chunk` carries `step`, as used by `turn-process`; a chunk without a step does not open a group.
|
||||
|
||||
## Retained alternative: parent-derived child Contexts
|
||||
|
||||
If builder materialization exposes more ownership problems than expected, add an engine primitive: a Context's `update` may derive a child Context with an explicit kind and id, then forward later matches. Each child owns its node, publication timing, and Location. Parent replay aligns children by id; children no longer derived become hidden under the existing withdrawal rules.
|
||||
|
||||
This returns row identity and content to Definitions with one Context per group and no array fragmentation. It requires assembler forwarding and cascaded replay, and introduces a Context creation path outside `match`. Reconsider it if more than two business features exceed the builder's materialization limits, or if membership requires cases that steps cannot express.
|
||||
-288
@@ -1,288 +0,0 @@
|
||||
# Agent Note: Chat 展示策略通道与过程分组的基础架构
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-09-20-chat-presentation-policy-and-step-groups.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Chat 的「对话显示」设置在本提案开始时只有两档:`normal` 不折叠、`compact` 在轮次结束后把过程行折进一个整轮控件。设置值的流向是:`TranscriptViewPolicy.mode` 观察量注入 ChatView,ChatView 用 `useTranscriptView` 读成一个布尔值 `compactTranscript`,再作为 prop 传给 `ChatNodeList`,再传给每一个 `ChatNodeSeat`。另一个全局布尔值 `hasMore` 走同一条路,以 `historyIncomplete` 的名字进每个 seat。两个值一变,全部 seat 重渲染一遍,哪怕只有可折叠的那几个输出真的变了。
|
||||
|
||||
产品要把这个设置改成三档:简洁、详细、完全展开;并在简洁和详细两档里把回复之间的连续过程行归成可折叠的组。PR #4565 做过一次完整实现,合并八分钟后因性能回退被整体撤回。合并前后在本机的对照实测:长会话分页耗时 +27%,DOM 节点 +23%,堆内存 +14%,Trajectory 切换 +10%,全部仍在 CI 预算内;仓库现有六个基准没有任何一个覆盖「流式期间 reasoning 或工具 chunk 进入活动分组、分组重投影、seat 重渲染」这条它新增的热路径。
|
||||
|
||||
审阅那次实现,问题不在业务需求,在选型:
|
||||
|
||||
- 模式被当成列表结构的输入。`ChatNodeList` 订阅整表 `layout`,按 range 生成 wrapper 组件;完全展开模式下 wrapper 消失。模式切换等于整棵列表重挂载,任何一次重新分组都重建 wrapper 的 key。
|
||||
- 模式进了 `ChatNodeOwnerProps`。每个 seat 的 owner 对象随模式变,所有业务渲染器跟着重渲染,包括与模式无关的用户消息、工具行、压缩标记。
|
||||
- 同一个 Assistant 节点在 seat 里被拆成 reasoning 与 response 两份派生对象,只为了让 wrapper 在 DOM 上包住前者不包住后者。master 的 `AssistantMarkdown` 早有 `reasoningHidden` 与 `revealProcess`,靠隐藏状态就能只藏 reasoning。
|
||||
- 基础设施行的 Chat 可见性由消费方过滤函数在四处各判一遍,而节点本身有 `visibility` 字段由 Definition 决定。
|
||||
|
||||
再往下一层,是一个信息流上的两难:分组的行要出现在成员之前,从组打开那一刻就该存在;但它要显示的内容,只有看到整轮所有事件的人才知道。事件驱动的 Context 模型里每个 Context 只收自己的事件,能看到整轮的只有 turn 级 Context,而它只能产出一个节点。
|
||||
|
||||
本记录给出一套基础架构,让三档模式与分组落地时满足下面的约束,并让后续产品变化只落在少数节点上。
|
||||
|
||||
三档设置、展示策略通道与按轮折叠资格已实现;step-group 仍是提案。当前实现、已提交范围和未完成项以[迁移进度表](../feature/2026-09-20-chat-work-details-migration.zh.md)为准,下面的分组状态机与改动清单不表示已经交付。
|
||||
|
||||
## 目标与约束
|
||||
|
||||
[已实现的 Definition 分组基础机制](../../implemented/architecture/2026-09-21-conversation-build-groups.zh.md)取代本记录中的事件 fold、Builder 物化组头、Step 范围成员推断及 changedLocationData 提案。本记录保留展示策略与按轮折叠资格的理由;下文分组实现记录已被取代的路线,不是当前实施方案。
|
||||
|
||||
下表是评审阶段已经确认的裁决,本方案不再重新讨论它们。
|
||||
|
||||
| 编号 | 裁决 | 含义 |
|
||||
|---|---|---|
|
||||
| 1 | 分组结构在 Definition 层定义 | 组的开合、成员范围、摘要由一个多重匹配本轮事件的 Definition 折叠出来,不由渲染层扫描节点得到 |
|
||||
| 2 | 有限刷 seat | 切模式只重渲染 presentation 真正变化的 seat;流式 chunk 只重渲染所属 seat 和它所在组的组头;用户消息、回复、turn-tail 在这两种变化下零重渲染 |
|
||||
| 3 | 一个 Context 一个节点 | 不引入「一个 Context 产出多个节点」,理由是绝大多数 Context 只会有一个元素,数组化让内存更零散 |
|
||||
| 4 | 模式不开关 Definition | 注册变化会触发 registry 重建与全量重放,所有 seat 必刷;Definition 必须与模式无关 |
|
||||
| 5 | 折叠按轮判定 | 一轮只要自己的 `turn/start` 已加载就可以折叠;被分页切开的最老一轮保持展开,本轮不优化 |
|
||||
| 6 | 允许改 ui-conversation | 引擎可以改,但只做让方案正确成立所必需的小改动 |
|
||||
| 7 | 初始实现范围 | 初始阶段只写代码与本记录;测试与其他文档在 CI 修复阶段补齐 |
|
||||
|
||||
有一条前置澄清:接受「切模式全屏重排版」不等于接受「所有 seat 重渲染」。浏览器重排是几何结果,seat 重渲染是 React 组件函数重跑,后者才是这里要限制的量。
|
||||
|
||||
## 可行的路线
|
||||
|
||||
围绕「组头这一行的身份从哪来、内容从哪来」,可行的路线有五条。
|
||||
|
||||
**路线 A,头成员的 seat 画组头。** 列表不变,builder 算出分组后告诉第一个成员的 seat「你是组头」,该 seat 在自己上方画标题,其余成员 seat 用现有 `hidden` 机制藏起来。零新概念。缺点是 seat 里出现「自身可见但只显示标题」的第三种状态,且标题寄生在一个业务节点的 seat 里。
|
||||
|
||||
**路线 α,每步一个 Context 当组头。** 增加一个 id 为 `turn:step` 的 Definition,在构建时读上一步的 `assistant-step` 数据判断自己是否新组开头,是则产出组头节点。组的内容由另一个 turn 级 Definition 发布成 Turn 数据供渲染器读取。
|
||||
|
||||
**路线 β,一个 Context 产出多个节点。** turn 级 Definition 一个人产出整轮控件加 N 个组头。
|
||||
|
||||
**路线 γ,父 Context 派生子 Context。** turn 级 fold 在开组时生成一个 `step-group` 子 Context 并把后续事件转发给它,子 Context 有自己的单个节点。引擎需要新增转发原语与重放级联。
|
||||
|
||||
**路线 δ,Definition 拥有结构,builder 物化行。** turn 级 Definition 折叠出组列表并发布为 Turn 数据;Chat builder 据此物化组头节点并给成员写归属;seat 用组合 selector 决定隐藏;引擎给 `ViewBuilder.apply` 的入参加一份「本次 flush 哪些 Location 数据换了引用」。
|
||||
|
||||
## 备选方案与取舍
|
||||
|
||||
**每组一个 Context 无法成立。** 引擎要求 `match(event)` 只看单个事件算出 id,「第几组」取决于前面有没有回复,单个事件算不出。这不是实现难度,是契约上的不可能。
|
||||
|
||||
**路线 α 与裁决 2 冲突。** 组头身份能算出,但内容要读整轮一份 Turn 数据,任何一组变化全轮组头都重渲染。改成去读 `turn.steps` 里后面几步的数据,又碰上组头节点不会因为后面的步到来而重建,读到的是陈旧引用。
|
||||
|
||||
**路线 β 被裁决 3 否决。** 见上表。
|
||||
|
||||
**路线 γ 现在不值。** 它在所有权上最纯,但要给 assembler 加转发与重放级联,是几百行的引擎核心改动。本记录把它保留为备选,条件写在末尾。
|
||||
|
||||
**路线 A 作为退路保留。** 如果 builder 物化节点在实践中被否决,退到 A;两者的结构来源相同,只差组头这一行的承载。
|
||||
|
||||
选择路线 δ。
|
||||
|
||||
## 方案
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
事件流
|
||||
──▶ step-groups Definition(turn 级 fold,多重匹配本轮事件)
|
||||
──▶ Turn 数据 step-groups:组列表,每组一个对象
|
||||
──▶ Chat builder
|
||||
├─ 物化组头节点:一组一个 key,data 即组对象
|
||||
└─ 成员归属:按 step 映射到 key,写进 per-key presentation
|
||||
──▶ seat:hidden = f(策略字段, 角色, 展开状态)
|
||||
──▶ 组头渲染器:读自己节点的 data
|
||||
展示策略
|
||||
──▶ 渲染器:按 kind 注入的 hooks,选单个字段
|
||||
──▶ seat:组合 selector,选出的是自己的结论
|
||||
```
|
||||
|
||||
引擎改动只有一处:assembler 在 flush 时把「哪些 Location 数据换了引用」随 `apply` 入参交给 builder。
|
||||
|
||||
### 各层职责
|
||||
|
||||
| 层 | 拥有 | 明确不拥有 |
|
||||
|---|---|---|
|
||||
| `step-groups` Definition | 组的开合、`headStep`、`closeStep`、工具计数与名称、运行中的工具;作为 Turn 数据发布 | 任何视图节点;成员是哪些节点 |
|
||||
| Chat builder | 组头节点的身份与生命周期;成员归属;`turnStarted`;per-key presentation 的身份比较发布 | 读事件;自己算业务结构 |
|
||||
| 展示策略对象 | 枚举到能力字段的映射 | 任何节点或 seat 状态 |
|
||||
| seat | 用策略字段、角色、store 状态合成 hidden;把组的开合能力交给组头渲染器 | 判断组的边界 |
|
||||
| 组头渲染器 | 标题、箭头、点击开合 | 组的内容来源 |
|
||||
| assistant-step 渲染器 | 是否显示已完成推理的首行预览;作为组的关闭者时隐藏推理部分 | 组的边界 |
|
||||
|
||||
### 组头节点的身份与生命周期
|
||||
|
||||
组头节点的 key 是 `step-group:${turn}:${headStep}`。`headStep` 在组打开那一刻就确定,之后任何事件都改不了「谁是第一个」,所以组头节点从打开起就存在并被复用,运行中不会换 key。组关闭只是它 data 里的 `closed` 从 false 变 true。
|
||||
|
||||
组头节点的锚点是该组头成员节点锚点减去一个合成偏移,位置紧贴在第一个成员之前。头成员的 Assistant 节点在流式期间锚在第一个可见 chunk,落盘后锚在消息事件,锚点会移动一次;组头随之移动一次,这是一次结构性更新,与 Assistant 节点自己的结构性更新同时发生,不额外增加次数。
|
||||
|
||||
组头节点属于整轮过程的一部分:整轮折叠时它随成员一起隐藏。它不在 `TURN_PROCESS_INDEPENDENT_KINDS` 里。
|
||||
|
||||
组头节点的 data 就是 fold 发布的组对象。已关闭的组对象引用永不改变,因此已关闭组头的 source 永不通知;正在运行的组每来一个改变它的事件换一个对象,只有这一组的组头重渲染。
|
||||
|
||||
### fold 状态机
|
||||
|
||||
`step-groups` Definition 的 id 是 turn 号,在 `turn/start` 上启动,匹配与 `turn-process` 同一批事件。State 是 `{ turn, groups, open }`,`groups` 是组对象数组,`open` 是当前打开组的下标或空。
|
||||
|
||||
| 事件 | 转移 |
|
||||
|---|---|
|
||||
| `tool/call` | 没有打开的组则以该事件的 step 开新组;计数加一;记录 distinct 工具名;把 callId 加入运行集合 |
|
||||
| `tool/result` 追加 | 从运行集合移除 callId |
|
||||
| `assistant/live-chunk` 推理 delta 含可见字符 | 没有打开的组则以该 chunk 的 step 开新组 |
|
||||
| `assistant/live-chunk` 文本 delta 或文本块结束含可见字符 | 有打开的组则以该 chunk 的 step 关闭它 |
|
||||
| `assistant/message` 追加 | 消息含可见推理则按推理规则处理,含可见回复则按文本规则处理;顺序是先推理后文本 |
|
||||
| `turn/end` | 有打开的组则标记为被轮次结束关闭 |
|
||||
| `step/start`、`step/end`、`llm/retry` | 不改变状态;重试行不属于任何组 |
|
||||
|
||||
三条不变量:
|
||||
|
||||
- 幂等。开组时已有打开的组则无操作;关组时组已关闭则无操作。live chunk 与落盘消息重复描述同一内容不会开两次或关两次,历史重放只有落盘消息也能得到同样的组列表。
|
||||
- 引用稳定。State 没变时 `update` 返回原对象;`groups` 数组只在组列表变化时换引用;已关闭的组对象永不替换。
|
||||
- 只向前折叠。不读邻居 Context,不读节点,不向后看。
|
||||
|
||||
Turn 数据发布:`buildLocationData` 只在 turn 阶段返回值,`groups` 引用不变时返回上一次发布的值,引擎据此不重新发布。没有 `turn/start` 的历史窗口用与 `turn-process` 相同的兜底:从已有匹配折叠出 State。
|
||||
|
||||
发布节奏:live chunk 走动画帧合并,其余立即。
|
||||
|
||||
### 成员归属规则
|
||||
|
||||
归属由 builder 按 step 判定,不用 seq 区间,因为 live chunk 的 seq 与落盘事件的 seq 不在一个尺度上。
|
||||
|
||||
| 节点 | 规则 |
|
||||
|---|---|
|
||||
| `tool-call`,step 为 s | 属于 `headStep ≤ s` 的最后一组。文本在同一 step 关闭了上一组之后的工具调用,会由 fold 以该 step 开出新组,所以「最后一组」总是正确的那一组 |
|
||||
| `assistant-step`,step 为 s,且某组 `closeStep === s` | 角色为关闭者。节点保持可见;组折叠时只隐藏它的推理部分,用现有 `reasoningHidden` 机制 |
|
||||
| `assistant-step`,step 为 s,其余情况 | 属于 `headStep ≤ s` 且 `s` 未越过 `closeStep` 的组,角色为成员 |
|
||||
| `model-retry`、`turn-error`、`turn-max-tokens`、`turn-tail`、`user`、`steering` | 不属于任何组 |
|
||||
|
||||
归属只在组列表变化时重算,成本是该轮节点数。流式 chunk 不改变组列表时,成员的 presentation 不发布。
|
||||
|
||||
组头行锚在最早的成员之前。一个组如果只有关闭者的推理而没有任何成员,比如一步里先推理后回复,组头行就锚在关闭者之前,这样被隐藏的推理仍然有一行可以点开。
|
||||
|
||||
### 分组变化怎样到达 builder
|
||||
|
||||
fold 只发布 Turn 数据,没有视图节点。engine 的 `flush` 在 Location 数据有变化时会把 `timelineDirty` 置真并对所有活跃目标调用 `apply`,但 builder 不知道是哪一轮的哪个 key 变了。依赖「同一轮总有别的节点也在这次 flush 里变了」去推断,是巧合不是契约。
|
||||
|
||||
所以给 `ConversationViewBuilder.apply` 的入参增加可选的 `changedLocationData`:本次 flush 里换了引用的 Location 数据清单,每项含 `kind`、`turn`、可选 `step`、`key`。assembler 在 `applyDirtyLocationData` 里本来就逐条比较了前后引用,只是把比较结果收集起来交给 builder。Chat builder 只取 `key === 'step-groups'` 的轮次做重投影。入参可选,不传时 builder 退化为按本次 upsert 涉及的轮次重投影。
|
||||
|
||||
### seat 的 hidden 合成
|
||||
|
||||
seat 不再接收 `compactTranscript` 与 `historyIncomplete` 两个 prop。它通过 `usePresentation` 读策略,但选出的不是模式,而是自己的结论:
|
||||
|
||||
```ts
|
||||
declare function usePresentation<T>(select: (policy: {
|
||||
stepGrouping: 'collapsed' | 'none'
|
||||
foldCompletedTurns: boolean
|
||||
}) => T): T
|
||||
declare const group: { readonly headStep: number } | undefined
|
||||
|
||||
const grouping = usePresentation(p => group === undefined ? 'none' : p.stepGrouping)
|
||||
const foldCompleted = usePresentation(p => p.foldCompletedTurns)
|
||||
```
|
||||
|
||||
不在任何组里的 seat,`grouping` 在三档下都是 `'none'`,值没变就不重渲染。`foldCompletedTurns` 在三档下都是 true,同理。selector hook 的底层是 `useSyncExternalStoreWithSelector`,只有选出的值变化才重渲染。
|
||||
|
||||
三个隐藏来源合成一个 `hidden`:
|
||||
|
||||
| 来源 | 条件 | 揭示动作 |
|
||||
|---|---|---|
|
||||
| 整轮折叠 | 现有规则,`turnClosed && turnStarted && 有回复` 且未手动展开 | 打开整轮 |
|
||||
| 组折叠 | `grouping === 'collapsed'`、角色为成员、组未手动展开 | 打开该组 |
|
||||
| 组头在完全展开档 | 角色为组头且 `grouping === 'none'` | 无 |
|
||||
|
||||
关闭者节点的推理部分单独隐藏:seat 通过 owner props 里的 `stepGroup.reasoningHidden` 告诉 assistant-step 渲染器。
|
||||
|
||||
组的展开状态进 chat store,key 是 `turn` 加 `headStep`,与整轮的手动展开状态并列。
|
||||
|
||||
### 刷新范围
|
||||
|
||||
下表是这套结构给出的保证,也是下一轮渲染次数测试的断言表。每一行断言的是精确集合,不是上限。
|
||||
|
||||
| 外部变化 | 允许重渲染的 |
|
||||
|---|---|
|
||||
| 运行中的组来一个推理 chunk | 该 assistant seat |
|
||||
| 运行中的组来一个 `tool/call` | 该 tool seat,加这一组的组头 |
|
||||
| 回复出现,组关闭 | 该 assistant seat,加这一组的组头;成员归属重算只发生在开关组时 |
|
||||
| 新组打开 | 新组头进 `order`,一次结构重排;其他 seat 不刷 |
|
||||
| 模式在简洁与详细之间切换 | 仅有已完成推理的 assistant-step 渲染器 |
|
||||
| 模式切到完全展开或切回 | 仅成员与组头 seat |
|
||||
| `performanceUsage` 切换 | 仅 turn-tail |
|
||||
| 加载更早一页 | 新增 seat,加 presentation 变了的历史 seat |
|
||||
| 语言切换 | 全部,作为对照 |
|
||||
|
||||
### 展示策略对象
|
||||
|
||||
持久化的枚举与运行时词汇分开。枚举 `TranscriptViewMode` 是 `compact`、`detailed`、`expanded`,存档里的旧值 `normal` 读取时映射为 `detailed`,不回写。运行时词汇是派生的 `ChatPresentationPolicy`:
|
||||
|
||||
| 字段 | compact | detailed | expanded |
|
||||
|---|---|---|---|
|
||||
| `foldCompletedTurns` | true | true | true |
|
||||
| `stepGrouping` | collapsed | collapsed | none |
|
||||
| `settledReasoningPreview` | false | true | true |
|
||||
|
||||
映射只存在于一处。每档对应一个常量对象,同一档永远同一引用,所以派生观察量不需要自己的订阅簿记:`getSnapshot` 是查表,`subscribe` 直接转给模式观察量。
|
||||
|
||||
渲染器不比较枚举,只 select 自己需要的字段。策略观察量按 kind 注入:assistant-step 的注册通过 `inject.hooks.presentation` 拿到它,与 turn-tail 注入 `performanceUsage` 是同一写法。以后加第四档只改这张表,渲染器一个不动。
|
||||
|
||||
### 按轮折叠
|
||||
|
||||
per-key presentation 增加 `turnStarted`,来源是该轮 `turn/start` 是否在加载窗口内。seat 的折叠资格由 `turnStarted` 替代原来的全局 `!historyIncomplete`。分页加载完成时不再有任何全局 prop 变化,只有新增节点与它们所在轮的 presentation 变化。
|
||||
|
||||
### 复杂度契约
|
||||
|
||||
| 场景 | fold | builder | 发布 |
|
||||
|---|---|---|---|
|
||||
| 内容型 chunk,不改变组边界 | O(1) 状态转移,返回原对象 | 无重投影 | 零 |
|
||||
| 改变组边界的事件 | O(1) | 只重投影所属轮,成本为该轮节点数 | 该轮内事实变化的 key |
|
||||
| 结构变化与分页 | 按匹配的事件数 | 全量一次 | 事实变化的 key |
|
||||
| 模式切换 | 无 | 无 | 无;重渲染由 seat 与渲染器的 selector 决定 |
|
||||
|
||||
## 改动清单
|
||||
|
||||
| 包 | 文件 | 改动 |
|
||||
|---|---|---|
|
||||
| ui-conversation | `contract/conversation.ts` | 新增 `ConversationLocationDataUpdate`;`ConversationViewBuilder.apply` 入参增加可选 `changedLocationData` |
|
||||
| ui-conversation | `conversation/assembler.ts` | `applyDirtyLocationData` 收集换了引用的 Location 数据并随 `apply` 传出 |
|
||||
| ui-conversation | `index.ts` | 导出新类型 |
|
||||
| ui-chat | `chat-settings.ts` | 三档枚举;schema 接受旧值 `normal` |
|
||||
| ui-chat | `transcript-view.ts` | 读取时 `normal` 映射为 `detailed` |
|
||||
| ui-chat | `presentation-policy.ts` 新增 | 策略对象、映射表、派生观察量 |
|
||||
| ui-chat | `settings/TranscriptViewRow.tsx` | 三个选项 |
|
||||
| ui-chat | `locale.ts` | 三档文案;组头文案 |
|
||||
| ui-chat | `contract/slots.ts` | `ChatViewInjected.hooks.presentation`;`PresentationInjected`;`ChatNodeOwnerProps.stepGroup`;`StepGroupOwnerProps` |
|
||||
| ui-chat | `contract/snapshot.ts` | `ChatTurnProcessPresentation.turnStarted`;`ChatNodeGroupPresentation`;`processSource` 的值改为 per-key 组合对象 |
|
||||
| ui-chat | `contract/chat-nodes.ts` | `StepGroupChatData` |
|
||||
| ui-chat | `contract/store.ts`、`stores.ts` | 组的手动展开状态与动作 |
|
||||
| ui-chat | `conversation-nodes/common.ts` | 组头锚点偏移 |
|
||||
| ui-chat | `conversation-nodes/step-groups.ts` 新增 | fold Definition 与 Turn 数据声明合并 |
|
||||
| ui-chat | `conversation-nodes/step-group-nodes.ts` 新增 | 组头节点物化与成员归属投影 |
|
||||
| ui-chat | `conversation-nodes/turn-process-presentation.ts` | `turnStarted`;组合 per-key presentation |
|
||||
| ui-chat | `conversation-nodes/chat-snapshot-builder.ts` | 接入物化投影;消费 `changedLocationData`;store 支持删除 key |
|
||||
| ui-chat | `conversation-nodes/register.ts` | 注册 fold Definition |
|
||||
| ui-chat | `chat/ChatView.tsx` | 删除 `compactTranscript` 与 `historyIncomplete` 的传递;传 `usePresentation` |
|
||||
| ui-chat | `chat/ChatNodeSeat.tsx` | 组合 selector;三个隐藏来源;`stepGroup` owner props |
|
||||
| ui-chat | `chat/AssistantNodeView.tsx`、`chat/AssistantMarkdown.tsx`、`chat/ReasoningRow.tsx` | 注入策略;已完成推理的预览开关;关闭者的推理隐藏 |
|
||||
| ui-chat | `chat/StepGroupNodeView.tsx` 新增 | 组头渲染器 |
|
||||
| ui-chat | `chat/register-node-renderers.ts` | 注册组头渲染器;给 assistant-step 注入策略 |
|
||||
| ui-chat | `apply.ts`、`index.ts` | 创建策略观察量并注入;导出类型 |
|
||||
|
||||
上述清单保留完整分组方案;独立功能已经更新 README、i18n 配对文件及 slot catalog。测试与快照进度见迁移记录,未完成的分组改动不随这些更新自动完成。
|
||||
|
||||
## 验收标准
|
||||
|
||||
分组相关验收仍待实现;独立展示功能的验证进度见迁移记录。
|
||||
|
||||
**渲染次数隔离测试。** 在现有 `chat-view.client.spec` 的挂载方式上加计数:React Profiler 的 `onRender` 按 seat 计数,测试专用的计数渲染器注册进 `conversation.chat.node` 按 kind 计数。刺激与期望按「刷新范围」一节的表逐行断言精确集合。
|
||||
|
||||
**发布次数测试。** 同样的刺激下统计每个 key 的 source 通知次数,断言精确集合。
|
||||
|
||||
**fold 状态机单测。** 喂事件序列,断言组列表:开组、关组、同 step 先文本后工具开新组、轮次结束关组、幂等性、已关闭组对象引用不变、只有落盘消息的历史重放得到相同组列表。
|
||||
|
||||
**成员归属单测。** 给定组列表与该轮节点,断言每个 key 的角色。
|
||||
|
||||
**基准补位。** `conversation-fold` 增加带推理与工具的增量 apply 场景;`long-session-browser` 增加带工具与推理的流式 fixture。这是墙钟兜底,不是门禁。
|
||||
|
||||
## 风险
|
||||
|
||||
- **builder 产出节点的边界。** 这是这套架构里第一次由视图 builder 产出节点。边界写死为:builder 只能从 Turn 或 Step 数据物化行,不能读事件,不能自己算业务结构。合成 key 用 `step-group:` 前缀,Context key 以数字开头,不会冲突。如果实践中被否决,退到路线 A。
|
||||
- **组头锚点随头成员移动一次。** 头成员是流式 Assistant 时,落盘让锚点从首个可见 chunk 移到消息事件,组头跟着移动一次。与 Assistant 自己的结构性更新同时发生。
|
||||
- **三档在本轮的实际差异。** 简洁与详细目前只差已完成推理的首行预览;运行中的详情标题、类别摘要等产品特性不在本轮。策略表已为它们留了位置。
|
||||
- **分页与半截轮。** 被分页切开的最老一轮不折叠、不分组;归属只按 step 计算,不受分页影响。
|
||||
- **组头文案。** 本轮只有「正在处理」与「已完成处理」加工具调用次数,类别摘要留给产品层。
|
||||
- **流式 chunk 的 step 字段。** `assistant/live-chunk` 事件携带 `step`,与 `turn-process` 的用法相同;若 chunk 缺少 step 则不开组。
|
||||
|
||||
## 备选保留:父 Context 派生子 Context
|
||||
|
||||
如果 builder 物化节点在使用中暴露出比预期更多的边界问题,替代方案是给引擎加一个原语:Context 在 `update` 中可以派生一个指定 kind 与 id 的子 Context,并把后续匹配转发给它;子 Context 有自己的单个节点、自己的发布节奏与 Location。父 Context 重放时子 Context 按 id 对齐,未再派生的子 Context 按现有撤回规则转为隐藏。
|
||||
|
||||
它的收益是行的身份与内容都回到 Definition 层,一组一个 Context,没有数组碎片。代价是 assembler 要新增转发与重放级联,且 `match` 之外多出一条 Context 创建路径。切换条件:builder 物化节点的边界在两个以上业务里被突破,或成员归属的按 step 规则出现无法用 step 表达的用例。
|
||||
@@ -1,6 +0,0 @@
|
||||
# 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/proposed/feature/2026-09-20-chat-work-details-migration.md
|
||||
2026-09-20-chat-work-details-migration.md: 126b61d32d4a4dbf27cb5997149abc9820d7f881
|
||||
2026-09-20-chat-work-details-migration.zh.md: 20727c299307881b67d0662092d269afa9af176c
|
||||
@@ -1,176 +0,0 @@
|
||||
# Agent Note: Chat work-details migration progress
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-09-20-chat-work-details-migration.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
PR #4565 combines three display modes, process grouping, whole-Turn status, trigger notifications, tool-row visuals, and infrastructure-row hiding. Migrating it as one batch mixes independent features with list restructuring and makes individual performance changes difficult to assess. A feature-based progress table allows independent visuals to be reviewed first and Turn-related behavior to be considered with step-group later, without losing product requirements.
|
||||
|
||||
## Proposal
|
||||
|
||||
Completed independent features stay in `feat/chat-presentation-channel`. The remaining unfinished code continues in `.artifacts/worktrees/pr-4565-turnstep` under the repository root, based on the current channel and this progress record, with drafts kept unstaged for combined turn/step and grouping review. The `pr-4565` worktree remains the original PR's behavior and implementation reference; existing `feat/chat-step-groups` experiments are outside this transfer.
|
||||
|
||||
This record tracks feature scope, current implementation, and acceptance progress. The [presentation policy channel and process-group architecture](../architecture/2026-09-20-chat-presentation-policy-and-step-groups.md) retains the architecture and alternatives. This record neither replaces it nor treats its proposals as implemented. Because it does not fully supersede that decision, retain the architecture note without archival or deletion.
|
||||
|
||||
This record maintains an English/Chinese pair. Completion, implementation, staging or commit state, and acceptance are separate dimensions. Incomplete items remain marked incomplete even when drafts exist, and stay in the inventory. Tests and documentation are added during CI repair; browser snapshots and performance acceptance are tracked separately. Written code, staging, commits, and successful builds do not establish complete functional or performance acceptance.
|
||||
|
||||
### Status definitions
|
||||
|
||||
| Dimension | State | Meaning |
|
||||
|---|---|---|
|
||||
| Completion | Complete | Migration code has been reviewed feature by feature and committed to this channel; behavior and performance evidence are separate |
|
||||
| Completion | Incomplete | Outside the channel's completed migration scope; may have an unstaged draft or no implementation |
|
||||
| Implementation | Existing capability | Already present in this channel |
|
||||
| Implementation | Code written | Implementation exists in the relevant worktree; does not imply completion, staging, commit, or acceptance |
|
||||
| Implementation | Awaiting migration | Included in non-grouping scope but not yet implemented |
|
||||
| Implementation | Grouping phase | Requirement retained; depends on grouping structure or interactions and is not connected to this channel yet |
|
||||
| Staging or commit | Unstaged | Changes remain in the worktree, including untracked files |
|
||||
| Staging or commit | Partially staged | Only some feature changes are staged; the exact scope must be stated |
|
||||
| Staging or commit | Staged | All pending code changes for the feature are staged but not committed |
|
||||
| Staging or commit | Committed | Feature code is included in current HEAD |
|
||||
| Acceptance | Pending | Requires behavior, node identity, update-scope, or browser performance evidence; Git state and compilation cannot substitute |
|
||||
|
||||
Verify staging against the feature's diff hunks, not whole files. Inspect the worktree, index, and HEAD when updating status. Staging some fields in a shared file does not stage every feature depending on that file.
|
||||
|
||||
### Capabilities already in this channel
|
||||
|
||||
| Capability | compact | detailed | expanded | Completion | Implementation | Staging or commit | Acceptance |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Three settings and legacy-value reading | Compact | Detailed; legacy `normal` reads as this mode without write-back | Expanded | Complete | Existing capability | Committed | Pending |
|
||||
| Per-Turn folding eligibility | A closed Turn with its start loaded and a final reply can fold process content; cancellation/failure extensions are item 6 | Same | Same | Complete | Existing capability | Committed | Pending |
|
||||
| Collapsed settled-reasoning summary | Think title only | First line | First line | Complete | Existing capability | Committed | Pending |
|
||||
| Retain summary DOM across mode changes | CSS hides summary and separator | Show existing summary and separator | Same | Complete | Existing capability | Committed | Pending |
|
||||
| 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` 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
|
||||
|
||||
“All three modes” means the behavior applies in every mode, not that switching modes changes it. “Without grouping dependencies” means the draft introduces no step-group, not that every item must be accepted before grouping. Numbers follow the feature review inventory; item 0's compilation wiring is complete and committed, so it is not listed as a product feature. Incomplete drafts remain in `pr-4565-turnstep`, outside this channel's completed features. Behavior and performance acceptance remain pending for the features below.
|
||||
|
||||
| Number | Feature | compact | detailed | expanded | Completion | Implementation | Staging or commit | Destination and considerations |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | Chinese tool titles | All three modes | All three modes | All three modes | Complete | Code written | Committed | Bash/Pwsh: “运行命令”; Grep: “搜索文件内容”; Glob: “查找文件”; Skill: “加载技能”. Only UI dictionaries change; model tool names do not |
|
||||
| 2 | Tool-row interaction visuals | All three modes | All three modes | All three modes | Complete | Code written | Committed | Hover darkens titles and summaries in tool, Bash, and command rows; shared `DisclosureRow` and independent Bash rows point the expanded chevron upward |
|
||||
| 3 | Bash stopped-state screen-reader text | All three modes | All three modes | All three modes | Complete | Code written | Committed | Complete the stopped-state accessible name without changing execution or cancellation semantics |
|
||||
| 4 | Running tool/command text shimmer | All three modes | All three modes | All three modes | Complete | Code written | Both 4A shimmer and 4B accompanying optimizations committed | 4A includes a fixed `TextShimmer` span, CSS activation, tool/command integration, replacement of old sweep styles, and reduced-motion support, excluding step/turn. 4B adds business-row computation caches, stable callbacks, and JSX references |
|
||||
| 5 | Streaming reasoning summary | Update after a paragraph's first line completes | Same | Same | Complete | Code written | Committed | `ReasoningRow` retains the original PR's `latestCompletedParagraphFirstLine(text)` and computes from full text. Retain summary DOM, stable props, and right-edge fading; add no Definition parsing state or cross-layer summary fields |
|
||||
| 6 | Keep process expanded after cancellation/failure | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft uses `keepOpen` in Turn process projection; folding eligibility and stopped/failed behavior await Turn/grouping review |
|
||||
| 7 | Whole-Turn status and duration control | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft anchors the control to loaded `turn/start` after opening inputs, isolates timing in `LiveTurnProcessLabel`, and replaces the bottom activity bar; positioning and status await review |
|
||||
| 8 | Non-human trigger notifications | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft identifies waking inputs from next-turn and first-step next-step claims and projects `turn-trigger`; Turn starts, notification ownership, and expanded content await review |
|
||||
| 9 (permission/system) | Hide permission-command and system-prompt rows | All three modes | All three modes | All three modes | Complete | Code written | Committed | `isVisibleChatNode` centralizes filtering for the existing Chat list, navigation, and process presentation. Command and request-prompt Definitions do not own these filters; persistence and Trajectory are unchanged |
|
||||
| 9 (context) | Hide ordinary Context rows | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Paired with item 8's waking notifications to retain source explanations; final filter ownership awaits Turn/grouping review |
|
||||
| 10 | Turn action-bar positioning | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft anchors `turn-tail` after `turn/end`; reply ownership for feedback/copy and the real Turn-end seq for branching await combined review |
|
||||
| 11 | Action-bar persistent visibility | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft projects `footer` per Turn in the Chat builder: persistently visible when the latest Turn ends in a reply, otherwise on hover/focus; tail detection will be considered with grouping |
|
||||
|
||||
The current base already enables developer tools by default; do not migrate that again. Retain the protection that waits for Host acceptance of settings instead of applying the original PR's missing-value fallback. Its `Switch` change only affects a comment and is not a migrated feature.
|
||||
|
||||
### Subsequent review batches
|
||||
|
||||
Review batches are independent of implementation and staging status. Review remaining drafts in `pr-4565-turnstep`, kept unstaged; a draft does not complete a migration. Transfer only this channel's remaining code, without automatically incorporating `feat/chat-step-groups` experiments or installing dependencies and compiling in the destination worktree.
|
||||
|
||||
| Batch | Features | Review scope |
|
||||
|---|---|---|
|
||||
| Independent feature, committed | 4A: text shimmer | `TextShimmer`, row integration, and browser painting cost; performance acceptance pending |
|
||||
| Independent optimization, committed | 4B: accompanying memoization and reference stability | `ToolRow`, `BashRow`, and `GenericCommandCard` computation caches, callbacks, and JSX references; not required for shimmer itself; performance acceptance pending |
|
||||
| Independent feature, committed | Permission-command and system-prompt hiding from item 9 | Retain the original PR's centralized visibility decision; without step-group, the main list connects it in `orderedVisibleChatNodes`, independent of waking-message classification |
|
||||
| Incomplete, transferred to turnstep worktree | 6, 7, 10, 11 | Whole-Turn folding eligibility, status/clock, action-bar position, and tail detection, considered with Turn/group relationships |
|
||||
| Incomplete, transferred to turnstep worktree | 8 and ordinary Context hiding from item 9 | Trigger notifications depend on Turn starts and Inbox claims; Context hiding accompanies notifications so waking reasons remain visible |
|
||||
|
||||
### Requirements retained for the grouping phase
|
||||
|
||||
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 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) |
|
||||
| Header update cadence | Preserve category title's minimum display duration | Also handle detail updates | Not applicable | Incomplete (grouping phase) |
|
||||
| Height-limited scrolling within a group | Limit expanded height, scroll independently, fade edges | Same | Not applicable | Incomplete (grouping phase) |
|
||||
| Group and whole-Turn folding interaction | Hide groups when the Turn folds | Same | Whole-Turn folding only | Incomplete; retain node identity and separately decide whether internal expansion resets |
|
||||
| Pagination, streaming settlement, and group identity | Stable existing headers and members; handle partial Turns separately | Same | Show the same business nodes directly | Incomplete (grouping phase) |
|
||||
|
||||
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 |
|
||||
|---|---|---|
|
||||
| Switching among modes | Business-node keys do not change; modes do not re-register Definitions; only relevant visibility/presentation fields update | Node identity and update-scope checks |
|
||||
| Incremental reasoning/tool content | Update only the owning node without scanning full history; compute reasoning summary from current full text during rendering | Update scope and long-text cost; the current summary algorithm does not reduce `ReasoningRow` render count |
|
||||
| Whole-Turn running clock | Only live clock content updates each second, without refreshing members, historical rows, or list | Update scope and timer disposal |
|
||||
| Summary visibility | Keep lightweight summaries mounted; do not mount every full Markdown body for mode switching | DOM identity, initial loading, memory observations |
|
||||
| Pagination | Update new nodes and nodes with changed facts; completed Turns do not refresh together because of global pagination state | Multi-page interaction and update-scope checks |
|
||||
| State transitions | Stable text/icon/callback references when content is unchanged; do not clear internal state through remounting | Interaction state and component lifecycle checks |
|
||||
|
||||
Retaining DOM reduces mounting and unmounting cost, not style calculation, layout, or painting, and can increase retained memory. Changing display properties alone is not a guarantee against jank. The aim is lower switching peaks with acceptable initial-load, pagination, and streaming costs.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Replay the original PR as one batch.** Rejected: grouping, node splitting, whole-Turn status, and independent visuals would mix, obscuring each feature's performance effects and overwriting local subscriptions and node reuse already completed in this channel.
|
||||
|
||||
**Unmount and recreate summaries or process nodes on mode changes.** Rejected for summary mode changes. Stable mounts allow repeated switching to change only display state; initial costs for new nodes still require separate validation.
|
||||
|
||||
**Keep every Markdown body mounted for smooth switching.** Rejected: lightweight summaries and full bodies differ in memory, DOM, and rendering cost. Bodies remain mounted according to manual expansion.
|
||||
|
||||
**Remount renderers when folding a Turn to reset tool cards.** Do not migrate directly. It conflicts with stable nodes and preserved interaction state. If reset behavior remains necessary, define its state owner and trigger explicitly.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Update implementation, staging, and commit status separately as work progresses. Completion follows feature review and commits; acceptance follows actual validation. Retain incomplete items across branch splits and worktree transfers. Before grouping work starts, check every product requirement and mode mapping above.
|
||||
|
||||
| Acceptance item | Current state |
|
||||
|---|---|
|
||||
| Complete behavior validation of existing channel capabilities | Relevant unit and browser regressions pass; this excludes unmigrated grouping features and does not establish performance acceptance |
|
||||
| Non-grouping feature migration | Completed scope is in the inventory; incomplete drafts await review in `pr-4565-turnstep`; functional and performance validation remain incomplete |
|
||||
| DOM identity and update scope during mode changes | Unit tests verify summary/expanded-body DOM identity and policy-field subscriptions; browsers verify persistence of all modes and whole-Turn folding; whole-page update scope and performance remain pending |
|
||||
| Long-session initial loading, pagination, streaming, and memory | Pending; the original PR's historical measurements do not validate this implementation |
|
||||
| Unit, browser, and recorded-snapshot coverage | 785 relevant unit tests pass; read-only replay of 35 browser files yields 178 passing tests and 4 conditional skips; UI expectations are updated and Session JSONL is unchanged |
|
||||
| Grouping and the Detailed/Expanded distinction | Incomplete (grouping phase) |
|
||||
|
||||
Validation evidence: after rebasing onto master, this channel passed full `pnpm run build`, lint, duplication checks, and all 41 documentation checks. New presentation policy, shimmer, and visibility filtering, plus the settings and Skill rows reported by CI, pass 100% coverage checks over their selected source scope. Test repairs, UI snapshots, and bilingual pairing are committed together as CI fixes. No install, build, or tests ran in `pr-4565-turnstep`; this channel's results do not establish acceptance for that worktree. A real-model GUI demo and performance measurements remain outstanding.
|
||||
|
||||
## Risks
|
||||
|
||||
- `expanded` and `detailed` currently present identically; the original PR's distinction exists only after grouping is implemented.
|
||||
- Whole-Turn duration replaces the existing tool/message-count title, changing product information. Migration must preserve status, accessible names, and Turns with no process content, not merely restyle the control.
|
||||
- Hiding ordinary Context before adding trigger notifications removes background waking reasons; connect both items in the inventory's order.
|
||||
- The original footer decision depends on grouping indexes. Rework ownership per feature rather than introducing the whole grouping infrastructure for a footer.
|
||||
- Group identity, membership, and builder materialization in the experiments still require evaluation; this record does not treat the experiment as final architecture.
|
||||
@@ -1,176 +0,0 @@
|
||||
# Agent Note: Chat 工作过程展示迁移进度
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-09-20-chat-work-details-migration.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发通知、工具行视觉和基础设施行隐藏。整批迁移会把独立功能与列表重组混在一起,也难以逐项判断性能变化。需要一份按功能维护的进度表,先评审独立视觉功能,再结合 step-group 统一考虑轮次相关行为,避免遗漏原有产品需求。
|
||||
|
||||
## 方案
|
||||
|
||||
已完成的独立功能保留在 `feat/chat-presentation-channel`。剩余未完成代码的后续工作区是仓库根目录下的 `.artifacts/worktrees/pr-4565-turnstep`,以当前通道及本进度记录为基线,草稿保持未暂存;后续结合 turn/step 与分组整体评审。`pr-4565` worktree 继续作为原 PR 的行为和实现参考;`feat/chat-step-groups` 的既有分组实验不在本次搬迁范围内。
|
||||
|
||||
本记录管理功能范围、当前实现和验收进度。[展示策略通道与过程分组的基础架构](../architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md)保留架构路线及备选方案;本记录不替代它,也不把其中的方案视为已经实现。新记录没有完整取代既有架构决策,因此保留原记录,不做归档或删除。
|
||||
|
||||
本记录维护中英文配对。完成状态、实现状态、暂存或提交状态、验收状态分别记录。未完成项即使已有代码草稿,也继续标为未完成,不从清单移除。测试和文档随 CI 修复补齐,浏览器快照与性能验收单独记录;代码写好、进入暂存区、提交或构建通过,都不等同于完整功能或性能验收。
|
||||
|
||||
### 状态定义
|
||||
|
||||
| 维度 | 状态 | 含义 |
|
||||
|---|---|---|
|
||||
| 完成 | 已完成 | 功能迁移代码已逐项审阅并提交到当前通道;行为与性能证据另行记录 |
|
||||
| 完成 | 未完成 | 尚未纳入当前通道的已完成迁移范围;可以已有未暂存草稿,也可以尚未编写 |
|
||||
| 实现 | 当前代码已有 | 当前通道的既有基础能力 |
|
||||
| 实现 | 代码已写好 | 对应工作区中已有实现;不代表已完成、已暂存、已提交或通过验收 |
|
||||
| 实现 | 待迁移 | 已纳入非分组迁移范围,当前尚未实现 |
|
||||
| 实现 | 分组阶段 | 产品需求保留,依赖分组结构或分组交互,暂不接入当前通道 |
|
||||
| 暂存或提交 | 未暂存 | 该功能的改动仍在工作区,包括尚未跟踪的新文件 |
|
||||
| 暂存或提交 | 部分暂存 | 该功能只有部分改动进入暂存区,必须注明具体范围 |
|
||||
| 暂存或提交 | 已暂存 | 该功能当前待提交的代码改动均已进入暂存区,尚未提交 |
|
||||
| 暂存或提交 | 已提交 | 该功能代码已包含在当前 HEAD 中 |
|
||||
| 验收 | 待验收 | 需要行为、节点身份、刷新范围或浏览器性能证据;不能用 Git 状态或编译结果代替 |
|
||||
|
||||
暂存状态按产品功能对应的差异片段核对,不按整个文件推断。更新状态时同时查看工作区、暂存区和 HEAD;共享文件只暂存部分字段,不能将依赖该文件的所有功能都标为已暂存。
|
||||
|
||||
### 当前通道已有的能力
|
||||
|
||||
| 能力 | compact | detailed | expanded | 完成状态 | 实现状态 | 暂存或提交状态 | 验收状态 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 三档设置与旧值读取 | 简洁 | 详细;旧值 `normal` 读作本档,不回写 | 完全展开 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
|
||||
| 按轮判断整轮折叠资格 | 已加载起点且存在最终回复的结束轮次,可收起过程内容;停止和失败的扩展行为见迁移项 6 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
|
||||
| 已完成推理的收起摘要 | 只显示「思考」 | 显示首行 | 显示首行 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
|
||||
| 模式切换时保留摘要 DOM | 用 CSS 隐藏摘要及分隔点 | 显示原有摘要及分隔点 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
|
||||
| 推理预览的局部订阅 | `ReasoningRow` 选择自己的可见性 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
|
||||
| 折叠行引用稳定 | 回调、图标及未变化的摘要保持引用;完整 Markdown 仅在展开时挂载 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
|
||||
|
||||
三档都保留整轮折叠。`expanded` 取消组级折叠、组头和限高,保留组身份与成员父级;并非禁止整轮折叠,也不是自动展开每个思考或工具卡的正文。分组尚未接入时,`detailed` 与 `expanded` 的 Chat 展示相同。
|
||||
|
||||
### 不依赖分组的迁移清单
|
||||
|
||||
下表的“三档共用”表示三档都应用该行为,不表示切换档位时会改变它。“不依赖分组”指实现草稿未引入 step-group,不代表都要先于分组接受。编号沿用逐项评审清单;编号 0 的编译接线修正已完成并提交,不作为产品功能另列。未完成项的草稿留在 `pr-4565-turnstep`,不是当前通道已完成的功能。下列功能的行为与性能均待验收。
|
||||
|
||||
| 编号 | 功能 | compact | detailed | expanded | 完成状态 | 实现状态 | 暂存或提交状态 | 迁移落点与注意事项 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | 工具标题中文化 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | Bash/Pwsh 为「运行命令」、Grep 为「搜索文件内容」、Glob 为「查找文件」、Skill 为「加载技能」;只改 UI 字典,不改模型工具名 |
|
||||
| 2 | 工具行交互视觉 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | 工具行、Bash 行和命令行悬停时标题及摘要加深;共享 `DisclosureRow` 和独立 Bash 行展开箭头朝上 |
|
||||
| 3 | Bash 停止状态的读屏提示 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | 补齐停止状态的可访问名称,不改变执行或取消语义 |
|
||||
| 4 | 工具及命令运行时文字扫光 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 4A 扫光与 4B 附带优化均已提交 | 4A 包含 `TextShimmer` 固定 span 与 CSS 启停、工具及命令行接入、旧扫光样式替换和减少动态效果支持,不含 step/turn;4B 为业务行额外的计算缓存、稳定回调及 JSX 引用 |
|
||||
| 5 | 流式推理摘要 | 段落首行写完后才更新 | 相同 | 相同 | 已完成 | 代码已写好 | 已提交 | `ReasoningRow` 沿用原 PR 的 `latestCompletedParagraphFirstLine(text)`,直接从完整文本计算;摘要 DOM、稳定 props 和右侧渐隐保留,不增加 Definition 解析状态或跨层摘要字段 |
|
||||
| 6 | 取消、失败后保持过程展开 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿在 Turn 过程投影中使用 `keepOpen`;轮次与分组阶段再评审折叠资格和停止、失败行为 |
|
||||
| 7 | 整轮状态与用时控件 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿将控件锚定到已加载的 `turn/start`、排在开场输入之后,以 `LiveTurnProcessLabel` 单独计时,并替换底部运行条;整体定位与状态待评审 |
|
||||
| 8 | 非人工触发通知 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿使用 next-turn 及首步 next-step 领取事实识别唤醒输入,投影 `turn-trigger`;轮次起点、通知归属和展开内容待评审 |
|
||||
| 9(permission/系统) | 隐藏 permission 命令与系统提示词行 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | `isVisibleChatNode` 集中判断,现有 Chat 列表、导航和过程展示复用;命令与请求提示词 Definition 不承载这些过滤规则,持久化数据与 Trajectory 不变 |
|
||||
| 9(context) | 隐藏普通上下文行 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 与 8 的唤醒通知配套,保留来源解释;过滤规则的最终归属留到轮次与分组阶段 |
|
||||
| 10 | 轮次操作栏位置 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿将 `turn-tail` 锚定到 `turn/end` 之后;反馈、复制的回复归属和分支的真实轮次结束序号待整体评审 |
|
||||
| 11 | 操作栏常显条件 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿由 Chat builder 按 Turn 投影 `footer`;最新轮以回复结尾时常显,其余悬停或聚焦显示,尾部判定与分组一起考虑 |
|
||||
|
||||
开发者工具默认开启已由当前基线提供,不重复迁移。当前实现保留等待 Host 接受设置后才启用相关能力的保护,不以原 PR 的缺值回退覆盖它。原 PR 的 `Switch` 改动只有注释,不作为功能迁移项。
|
||||
|
||||
### 后续评审批次
|
||||
|
||||
评审批次独立于实现和暂存状态。未完成草稿的后续评审统一在 `pr-4565-turnstep`,保持未暂存;已有草稿不代表迁移已完成。仅搬迁当前通道的剩余代码,不自动合入 `feat/chat-step-groups` 的实验,也不在目标工作区安装依赖或编译。
|
||||
|
||||
| 批次 | 功能 | 评审范围 |
|
||||
|---|---|---|
|
||||
| 独立功能,已提交 | 4A:文字扫光 | `TextShimmer` 本身、各行的调用方式和浏览器绘制成本;性能待验收 |
|
||||
| 独立优化,已提交 | 4B:附带的 memo 与引用稳定化 | `ToolRow`、`BashRow` 和 `GenericCommandCard` 的计算缓存、回调和 JSX 引用;不是扫光功能的必要条件,性能待验收 |
|
||||
| 独立功能,已提交 | 9 中的 permission 命令与系统提示词隐藏 | 沿用原 PR 的集中可见性判断;当前无 step-group,主列表改在 `orderedVisibleChatNodes` 接入,不依赖唤醒消息分类 |
|
||||
| 未完成,转入 turnstep 工作区 | 6、7、10、11 | 整轮折叠资格、状态与时钟、操作栏位置和尾部判定,统一考虑轮次与分组关系 |
|
||||
| 未完成,转入 turnstep 工作区 | 8、9 中的普通 context 隐藏 | 触发通知依赖轮次起点和 Inbox 领取判定;隐藏普通上下文与通知配套,避免先隐藏唤醒原因 |
|
||||
|
||||
### 分组阶段保留的需求
|
||||
|
||||
这些项目均未完成,继续保留全部需求,后续在 turn/step 与分组阶段处理。[Definition 拥有分组的基础机制](../../implemented/architecture/2026-09-21-conversation-build-groups.zh.md)已经实现,但不代表这些产品行为或性能要求已经完成。
|
||||
|
||||
| 功能 | compact | detailed | expanded | 当前状态 |
|
||||
|---|---|---|---|---|
|
||||
| 回复之间的连续过程分组 | 工具与推理归组,默认收起 | 相同 | 过程行不做组级折叠,保留成员父级 | 未完成(分组阶段) |
|
||||
| 同一 Assistant 中推理与回复的分别显隐 | 推理可归入前组,回复外露 | 相同 | 不做二级分组隐藏 | 未完成;不直接复制原版拆成两个渲染节点的实现 |
|
||||
| 组头类别摘要 | 运行时显示当前类别,完成后聚合主要类别 | 相同 | 无组头 | 未完成(分组阶段) |
|
||||
| 运行中组头详情 | 不显示 | 显示命令、路径、查询等详情,无运行工具时显示推理摘要 | 无组头 | 未完成(分组阶段) |
|
||||
| 组头内容更新节奏 | 保留类别标题的最短展示时间 | 同时处理详情更新 | 不适用 | 未完成(分组阶段) |
|
||||
| 组内限高滚动 | 展开内容限高、独立滚动、边缘渐隐 | 相同 | 不适用 | 未完成(分组阶段) |
|
||||
| 分组与整轮折叠联动 | 整轮收起时隐藏分组 | 相同 | 仅整轮折叠 | 未完成;保留节点身份,内部开合状态是否重置单独确定 |
|
||||
| 分页、流式结算与组身份 | 既有组头和成员稳定,半截轮单独处理 | 相同 | 同一批业务节点直接展示 | 未完成(分组阶段) |
|
||||
|
||||
分组产品细节的核对项:完成摘要最多聚合前三类,超过三类使用「等」;调用计数用于分类排序,不直接显示为组头数字。详细模式的实时详情合并空白并限制为 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 揭示、选择复制及中断提示归属。 |
|
||||
|
||||
## 性能约束
|
||||
|
||||
| 场景 | 要保留的性质 | 所需证据 |
|
||||
|---|---|---|
|
||||
| 切换三档模式 | 不改变业务节点 key,不因模式切换重新注册 Definition;仅相关可见性或展示字段更新 | 节点身份和刷新范围检查 |
|
||||
| 推理或工具内容增量更新 | 只更新所属节点,不扫描完整历史;推理摘要在渲染时从当前完整文本计算 | 更新范围与长内容计算开销;当前摘要算法不减少 `ReasoningRow` 的渲染次数 |
|
||||
| 整轮运行时钟 | 只有运行中的时钟内容按秒更新,不带动整轮成员、历史行和列表 | 更新范围及计时器释放检查 |
|
||||
| 摘要显隐 | 轻量摘要常驻;完整 Markdown 正文不为模式切换而全部常驻 | DOM 身份、首次加载和内存观察 |
|
||||
| 分页 | 新增节点与事实变化的节点更新,已完成轮次不因全局分页状态一起刷新 | 多页历史交互与刷新范围检查 |
|
||||
| 状态切换 | 文字、图标、回调引用在内容未变时稳定;不通过重挂载清空内部状态 | 交互状态与组件生命周期检查 |
|
||||
|
||||
保留 DOM 只减少挂载和卸载成本,不消除样式计算、布局与绘制,也会增加常驻内存。不能将“只改显示属性”写成“不会卡顿”的保证。性能目标是降低切换峰值,并保持首次加载、分页和流式更新的成本可接受。
|
||||
|
||||
## 备选方案与取舍
|
||||
|
||||
**整批重放原 PR。** 不采用。分组、节点拆分、整轮状态和独立视觉功能混在一起,难以把性能变化对应到具体功能,也会覆盖当前通道已完成的局部订阅与节点复用。
|
||||
|
||||
**切换模式时卸载、重新创建摘要或过程节点。** 摘要模式切换不采用该方式。稳定挂载让来回切换只改变显示状态;新增节点的初始成本仍须单独检查。
|
||||
|
||||
**为了切换平滑,把所有 Markdown 正文常驻。** 不采用。轻量摘要与完整正文的内存、DOM 和渲染成本不同;正文继续按手动展开状态挂载。
|
||||
|
||||
**收起整轮时重挂载 renderer,以重置工具卡。** 不直接迁移。该实现与稳定节点及保留交互状态的方向冲突;若后续仍需要重置行为,应明确状态所有者和触发规则。
|
||||
|
||||
## 验收标准
|
||||
|
||||
每项代码写好、暂存或提交后,分别更新对应状态;完成状态按逐项评审和提交记录更新,验收只按实际验证记录更新。未完成项保留,不因拆分分支或搬迁工作区而删除。分组阶段开始前,逐行复核上面的产品需求和模式对应关系。
|
||||
|
||||
| 验收项 | 当前状态 |
|
||||
|---|---|
|
||||
| 当前通道已有能力的完整行为验证 | 已通过本次相关单元和浏览器回归;不包含未迁移的分组功能,也不代表性能验收 |
|
||||
| 非分组功能迁移 | 完成范围见迁移清单;未完成草稿留到 `pr-4565-turnstep` 继续评审,功能与性能验证仍未完成 |
|
||||
| 模式切换时的 DOM 身份和刷新范围 | 单元测试已验证摘要、手动展开正文的 DOM 身份和策略字段订阅;浏览器已验证三档持久化及整轮折叠,整页刷新范围与性能仍待验收 |
|
||||
| 长会话首次打开、分页、流式输入与内存 | 待验收;原 PR 的历史测量不代表本实现 |
|
||||
| 新行为的单元、浏览器与录制快照覆盖 | 相关单元测试 785 项通过;35 个浏览器文件只读回放,178 项通过、4 项按条件跳过;UI 预期输出已更新,Session JSONL 未改动 |
|
||||
| 分组功能与两档差异 | 未完成(分组阶段) |
|
||||
|
||||
验证记录:当前通道在 rebase master 后通过完整 `pnpm run build`、lint、重复代码检查和完整文档检查(41 项)。新增展示策略与扫光、可见性过滤,以及 CI 报告的设置行和 Skill 行覆盖率已通过对应源码范围的 100% 检查。测试修复、UI 快照和中英配对随 CI 修复一并提交;`pr-4565-turnstep` 未执行安装、构建或测试,当前通道的结果不代表该工作区已验收。真实模型 GUI 演示和性能测量仍待补。
|
||||
|
||||
## 风险
|
||||
|
||||
- 当前 `expanded` 与 `detailed` 的展示相同;只有分组阶段完成后,两档才具备原 PR 设计的区别。
|
||||
- 整轮用时控件替换现有工具和消息计数标题,属于产品信息变化;迁移时不能只换样式而遗漏状态、可访问名称或空过程轮次。
|
||||
- 普通上下文隐藏若先于触发通知,会让后台唤醒原因消失;两项需按清单顺序衔接。
|
||||
- 原 PR 的页脚判定与分组索引耦合,需要按功能重写归属,不能为页脚单独引入整套分组基础设施。
|
||||
- 分组实验中的组身份、成员归属和 builder 物化路线仍需继续评估;本记录不将实验实现列为最终架构。
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { expandOwningTurnProcess, newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
|
||||
import { AUTO_REVIEW_FIXTURE, captureAutoReviewState } from './auto-review-fixture.ts'
|
||||
|
||||
@@ -52,9 +52,10 @@ describe.skipIf(MODE === 'record')('web e2e: cold Auto-review denial', () => {
|
||||
expect(result?.data).not.toHaveProperty('callId')
|
||||
|
||||
scaffold = await launchWebScaffold(AUTO_REVIEW_FIXTURE)
|
||||
await seedSession(scaffold, fixture, SEED_ID)
|
||||
await seedSession(scaffold, fixture, SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.clock.setFixedTime(WEB_FIXTURE_TIME)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { expandTurnProcesses, newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/acp/cancel-tool-calls/session.v3.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/bash-abort-row', import.meta.url))
|
||||
@@ -30,9 +30,10 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', ()
|
||||
const fixture = await readFile(FIXTURE, 'utf8')
|
||||
expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, fixture, SEED_ID)
|
||||
await seedSession(scaffold, fixture, SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.clock.setFixedTime(WEB_FIXTURE_TIME)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
@@ -43,6 +44,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', ()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
await expandTurnProcesses(page)
|
||||
await page.locator('[data-sample="bash"]').nth(1).waitFor({ timeout: 15_000 })
|
||||
}, 120_000)
|
||||
|
||||
@@ -67,9 +69,6 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', ()
|
||||
await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(2)
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
// The borrowed fixture's UTC date is still the previous day in PDT;
|
||||
// the disclosure golden must not depend on the runner timezone.
|
||||
.replace(/\b\d{1,2}\/\d{1,2}(?= \{\{clock\}\})/g, '{{date}}')
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
|
||||
@@ -712,6 +712,7 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
|
||||
const liveRow = world.page.locator(`[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`)
|
||||
await expandOwningTurnProcess(world.page, liveRow)
|
||||
await liveRow.waitFor({ timeout: 15_000 })
|
||||
expect(await liveRow.getAttribute('data-state')).toBe('running')
|
||||
await expectBottom(world.page)
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { openSettingsFromAccountMenu, expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/clickable-links-gallery', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/clickable-links-gallery/ui.expected.md', import.meta.url))
|
||||
@@ -373,11 +373,9 @@ describe('web e2e: clickable links gallery', () => {
|
||||
// no openable path even though its create still joins the produced chips.
|
||||
expect(await page.locator('button[class*="fileLink"]').count()).toBe(7)
|
||||
|
||||
// Expanded cards. The turn-process group collapses a multi-call turn, so
|
||||
// it opens first. Rows expand via a right-edge click: the row center can
|
||||
// land on the nested fileLink button, which would hand the path to the
|
||||
// Host's opener.
|
||||
await page.getByRole('button', { name: `${String(CALLS.length)} tool calls` }).click()
|
||||
// A row-center click can hit the nested fileLink button and invoke the
|
||||
// Host opener; right-edge clicks expand the card itself.
|
||||
await expandTurnProcesses(page)
|
||||
for (const row of [
|
||||
/^Search clickable link styles/,
|
||||
/^Fetch /,
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
- tab "Trajectory"
|
||||
- text: "Assemble the link gallery: write the report and styles, inspect the sources, and summarize. {{clock}}"
|
||||
- button "Copy"
|
||||
- button "16 tool calls" [expanded]
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [expanded]
|
||||
- button "Edited files, called tools, searched code, etc." [expanded]
|
||||
- button "Write site/report.html +1 -0":
|
||||
- text: Write
|
||||
- button "site/report.html"
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
- button "Jump to turn 3"
|
||||
- text: "Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop. 9/1 {{clock}}"
|
||||
- button "Copy"
|
||||
- button "3 tool calls" [expanded]
|
||||
- button "Context injection runtime-context"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [expanded]
|
||||
- button "Called tools" [expanded]
|
||||
- button "Think"
|
||||
- button "Inspect plugins 0 dynamic plugins"
|
||||
- button "Think"
|
||||
@@ -37,7 +38,11 @@
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- text: 9/1 {{clock}}
|
||||
- button "Thought for a while"
|
||||
- button "Plugin status updated 9/1 {{clock}}":
|
||||
- text: Plugin status updated
|
||||
- time: 9/1 {{clock}}
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: The Cordis Plugin is running.
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -45,7 +50,9 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 9/1 {{clock}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. 9/1 {{clock}}
|
||||
- button "Copy"
|
||||
- button "1 tool call" [expanded]
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [expanded]
|
||||
- button "Called tools" [expanded]
|
||||
- text: Stop Cordis Plugin snap-1
|
||||
- button "Inspect"
|
||||
- text: Dynamic Plugin snap-1 is stopped; its definition and versions remain.
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}"
|
||||
- button "Copy"
|
||||
- button "Think"
|
||||
- text: Failed
|
||||
- 'button "Read The history inherited by this branch records this tool call starting but does not include its result. The parent session may have completed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."'
|
||||
- text: Failed
|
||||
- 'button "Tool call The history inherited by this branch has no record of this tool call starting. The parent session may have executed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."'
|
||||
- status: Worked
|
||||
- button "Took {{duration}}"
|
||||
- text: FORK_BRANCH_USER Summarize what this branch knows about the reads. {{clock}}
|
||||
- button "Copy"
|
||||
- button "Thought for a while"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: FORK_BRANCH_FIRST this branch has no read results, so I would check what the parent completed before retrying. FORK_BRANCH_DONE.
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- button "Thought for a while" [expanded]
|
||||
- button "Context injection webhook github webhook handled by review-pr-when-ready"
|
||||
- button "Context injection runtime-context"
|
||||
- button "GitHub event received {{clock}}":
|
||||
- text: GitHub event received
|
||||
- time: {{clock}}
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: "Review complete: no actionable findings."
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- button "Thought for a while"
|
||||
- button "GitHub event received {{clock}}":
|
||||
- text: GitHub event received
|
||||
- time: {{clock}}
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: "Review complete: no actionable findings."
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
- tab "Trajectory"
|
||||
- text: Render adjacent CJK strong emphasis. {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- heading "CJK strong emphasis" [level=2]
|
||||
- paragraph:
|
||||
- strong: 注意:
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
- tab "Trajectory"
|
||||
- text: Show the Markdown image policy. {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- heading "Markdown images" [level=2]
|
||||
- paragraph:
|
||||
- img "Remote test image"
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
- tab "Trajectory"
|
||||
- text: Show the local preview URL. {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- heading "Inline code links" [level=2]
|
||||
- paragraph:
|
||||
- text: "Preview:"
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
- tab "Trajectory"
|
||||
- text: Render this mathematical proof. {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- heading "Math rendering" [level=2]
|
||||
- paragraph:
|
||||
- text: Inline dollar
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
- tab "Trajectory"
|
||||
- text: Research notes what changed? Referenced session · Research notes {{clock}}
|
||||
- button "Copy"
|
||||
- button "Session recall Research notes"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands"
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
- button "meeting notes.md"
|
||||
- text: and confirm the fixture wiring {{clock}}
|
||||
- button "Copy"
|
||||
- button "Thought for a while" [expanded]
|
||||
- button "Context injection runtime-context"
|
||||
- button "Context injection user-invoke-demo"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
- button "meeting notes.md"
|
||||
- text: and confirm the fixture wiring {{clock}}
|
||||
- button "Copy"
|
||||
- button "Thought for a while"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
- button "Jump to turn 28"
|
||||
- text: m1 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r1
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -43,6 +45,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m2 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r2
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -50,6 +54,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m3 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r3
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -57,6 +63,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m4 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r4
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -64,6 +72,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m5 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r5
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -71,6 +81,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m6 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r6
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -78,6 +90,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m7 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r7
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -85,6 +99,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m8 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r8
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -92,6 +108,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m9 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r9
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -99,6 +117,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m10 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r10
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -106,6 +126,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m11 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r11
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -113,6 +135,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m12 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r12
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -120,6 +144,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m13 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r13
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -127,6 +153,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m14 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r14
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -134,6 +162,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m15 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r15
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -141,6 +171,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m16 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r16
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -148,6 +180,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m17 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r17
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -155,6 +189,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m18 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r18
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -162,6 +198,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m19 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r19
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -169,6 +207,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m20 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r20
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -176,6 +216,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m21 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r21
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -183,6 +225,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m22 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r22
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -190,6 +234,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m23 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r23
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -197,6 +243,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m24 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r24
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -204,6 +252,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m25 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r25
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -211,6 +261,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m26 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r26
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -218,6 +270,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m27 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r27
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
@@ -225,6 +279,8 @@
|
||||
- button "Branch into a new conversation"
|
||||
- text: 7/25 {{clock}} m28 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- paragraph: r28
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
- tab "Trajectory"
|
||||
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
|
||||
- button "Copy"
|
||||
- button "Context injection runtime-context"
|
||||
- button "Think"
|
||||
- status: Deep diving...
|
||||
- button "Deep diving for {{duration}}" [disabled] [expanded]
|
||||
- button "Analyzing the request"
|
||||
- text: "Interjection: include the word BANANA in your final reply."
|
||||
- button "Copy"
|
||||
- text: "Interjection: include the word ORANGE in your final reply."
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
- tab "Trajectory"
|
||||
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
|
||||
- button "Copy"
|
||||
- button "1 tool call" [expanded]
|
||||
- button "Context injection runtime-context"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- button "Asked questions" [expanded]
|
||||
- button "Think"
|
||||
- button "Ask question 1/1 answered"
|
||||
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
- tab "Trajectory"
|
||||
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
|
||||
- button "Copy"
|
||||
- button "1 tool call"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- button "Asked questions"
|
||||
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
|
||||
- button "Copy"
|
||||
- text: "Interjection: include the word ORANGE in your final reply. {{clock}}"
|
||||
|
||||
@@ -339,6 +339,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
await input.press('Enter')
|
||||
if (MODE !== 'record') {
|
||||
const thinking = page.locator('[data-variant="think"][data-state="running"]')
|
||||
await expandOwningTurnProcess(page, thinking)
|
||||
await expect.poll(() => thinking.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
await reasoningComplete.promise
|
||||
expect(await thinking.getAttribute('data-preview')).toBeNull()
|
||||
|
||||
@@ -50,6 +50,7 @@ const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-se
|
||||
// model call.
|
||||
const PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.'
|
||||
const RUNNING_DRAFT = 'Queue this follow-up while the current turn is running.'
|
||||
const RETRY_PARTIAL = 'This partial reply must disappear when the attempt fails.'
|
||||
|
||||
/** turn/end reasons observed, in order. */
|
||||
function turnEndReasons(events: SessionEvent[]): string[] {
|
||||
@@ -270,21 +271,44 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
expect(derived).toHaveLength(1)
|
||||
await launch(() => ({
|
||||
patches: [
|
||||
{ at: 0, entry: { kind: 'throw', chunks: [], message: 'upstream 503', code: 'SERVER' } },
|
||||
{ at: 0, entry: {
|
||||
kind: 'throw', message: 'upstream 503', code: 'SERVER',
|
||||
chunks: [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: RETRY_PARTIAL },
|
||||
],
|
||||
} },
|
||||
// Append the fixture's own success as the retry attempt — single-
|
||||
// sourced from the recording, never copied into a committed sidecar.
|
||||
{ at: 1, entry: derived[0]! },
|
||||
],
|
||||
}))
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-retry'))
|
||||
// llm-retry backs off ~500ms before the second attempt.
|
||||
const { settled } = await sendPrompt(60_000)
|
||||
await settled
|
||||
const releaseFailure = Promise.withResolvers<undefined>()
|
||||
// Retirement must follow a visible partial, not race the first browser paint.
|
||||
const stopHolding = scaffold!.ctx.on('llm/stream', async function* (_request, next) {
|
||||
for await (const chunk of next()) {
|
||||
yield chunk
|
||||
if (chunk.type === 'text-delta' && chunk.text === RETRY_PARTIAL) await releaseFailure.promise
|
||||
}
|
||||
})
|
||||
try {
|
||||
const { settled } = await sendPrompt(60_000)
|
||||
await page.getByText(RETRY_PARTIAL, { exact: true }).waitFor({ timeout: 15_000 })
|
||||
releaseFailure.resolve(undefined)
|
||||
await settled
|
||||
} finally {
|
||||
releaseFailure.resolve(undefined)
|
||||
stopHolding()
|
||||
}
|
||||
expect(turnEndReasons(sessionEvents).at(-1)).toBe('completed')
|
||||
// The durable retry record proves the second attempt (request/header logs
|
||||
// only on change, so attempt count is invisible there).
|
||||
expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBeGreaterThanOrEqual(1)
|
||||
await expect.poll(() => page.getByText('event sourcing', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThan(0)
|
||||
await expect.poll(() => page.getByText(RETRY_PARTIAL, { exact: true }).count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
// Golden of the recovered end-state: the discarded partial stays absent,
|
||||
// while the settled retry row remains as durable recovery context.
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/markdown-cjk-strong', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/markdown-cjk-strong/ui.expected.md', import.meta.url))
|
||||
@@ -36,7 +36,6 @@ const CASES = [
|
||||
/** Build one settled assistant reply covering CJK-adjacent strong punctuation boundaries. */
|
||||
function markdownFixture(): string {
|
||||
const session = Session.create(SessionId('markdown-cjk-strong-source'))
|
||||
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Render adjacent CJK strong emphasis.' }],
|
||||
@@ -81,7 +80,7 @@ function markdownFixture(): string {
|
||||
}),
|
||||
...session.snapshotEvents().map(event => JSON.stringify({
|
||||
...event,
|
||||
time: eventTimeOrigin + event.seq * 1_000,
|
||||
time: WEB_FIXTURE_TIME + event.seq * 1_000,
|
||||
})),
|
||||
'',
|
||||
].join('\n')
|
||||
@@ -95,9 +94,10 @@ describe('web e2e: CJK-adjacent Markdown strong emphasis', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, markdownFixture(), SEED_ID)
|
||||
await seedSession(scaffold, markdownFixture(), SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.clock.setFixedTime(WEB_FIXTURE_TIME)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/markdown-inline-code-links', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/markdown-inline-code-links/ui.expected.md', import.meta.url))
|
||||
@@ -27,7 +27,6 @@ const LINK_URL = 'http://127.0.0.1:3199/?demo=1'
|
||||
/** Build a settled assistant reply with linkable URL code and inert code controls. */
|
||||
function markdownFixture(linkUrl: string): string {
|
||||
const session = Session.create(SessionId('markdown-inline-code-links-source'))
|
||||
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Show the local preview URL.' }],
|
||||
@@ -79,7 +78,7 @@ function markdownFixture(linkUrl: string): string {
|
||||
}),
|
||||
...session.snapshotEvents().map(event => JSON.stringify({
|
||||
...event,
|
||||
time: eventTimeOrigin + event.seq * 1_000,
|
||||
time: WEB_FIXTURE_TIME + event.seq * 1_000,
|
||||
})),
|
||||
'',
|
||||
].join('\n')
|
||||
@@ -95,9 +94,10 @@ describe('web e2e: Markdown inline-code links', () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
extraOverlayPath: fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)),
|
||||
})
|
||||
await seedSession(scaffold, markdownFixture(LINK_URL), SEED_ID)
|
||||
await seedSession(scaffold, markdownFixture(LINK_URL), SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.clock.setFixedTime(WEB_FIXTURE_TIME)
|
||||
await page.route('http://127.0.0.1:3199/**', async route => route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: '<h1>Inline-code preview</h1>',
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/math-rendering', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/math-rendering/ui.expected.md', import.meta.url))
|
||||
@@ -26,7 +26,6 @@ const DONE = 'MATH_RENDERING_DONE'
|
||||
/** Build a settled assistant reply that exercises every supported math delimiter. */
|
||||
function mathFixture(): string {
|
||||
const session = Session.create(SessionId('math-rendering-source'))
|
||||
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
@@ -82,7 +81,7 @@ function mathFixture(): string {
|
||||
}),
|
||||
...session.snapshotEvents().map(event => JSON.stringify({
|
||||
...event,
|
||||
time: eventTimeOrigin + event.seq * 1_000,
|
||||
time: WEB_FIXTURE_TIME + event.seq * 1_000,
|
||||
})),
|
||||
'',
|
||||
].join('\n')
|
||||
@@ -96,9 +95,10 @@ describe('web e2e: settled Markdown math rendering', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, mathFixture(), SEED_ID)
|
||||
await seedSession(scaffold, mathFixture(), SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.clock.setFixedTime(WEB_FIXTURE_TIME)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
@@ -152,7 +152,14 @@ describe('minimal agent preset', () => {
|
||||
await process.click()
|
||||
await expect.poll(() => process.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
const row = page.locator('[data-sample="bash"]').first()
|
||||
const group = page.locator('[data-chat-group-key]').filter({ has: page.locator('[data-sample="bash"]') }).first()
|
||||
const groupControl = group.locator('[data-process-activity]')
|
||||
await groupControl.waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => groupControl.getAttribute('aria-expanded')).toBe('false')
|
||||
const row = group.locator('[data-sample="bash"]').first()
|
||||
expect(await row.isVisible()).toBe(false)
|
||||
await groupControl.click()
|
||||
await expect.poll(() => groupControl.getAttribute('aria-expanded')).toBe('true')
|
||||
await row.waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
|
||||
await row.click()
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture,
|
||||
watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage } from './support.ts'
|
||||
import { connectFreshWorkspace, expandTurnProcesses, newEnglishPage } from './support.ts'
|
||||
|
||||
const DIR = fileURLToPath(new URL('../../../snapshots/web/present', import.meta.url))
|
||||
const FIXTURE = join(DIR, 'session.v3.jsonl')
|
||||
@@ -195,7 +195,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
|
||||
await expect.poll(() => page.locator('[data-presented-file] [role="status"]').count(), { timeout: 10_000 }).toBe(0)
|
||||
const aria = await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE)
|
||||
await page.locator('[data-turn-process]').click()
|
||||
await expandTurnProcesses(page)
|
||||
const failed = page.locator('[data-tool="present"][data-state="error"]')
|
||||
const delivered = page.locator('[data-tool="present"][data-state="ok"]')
|
||||
expect(await failed.count()).toBe(1)
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
fixtureUserPrompts, launchWebScaffold, seedSession, webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/pwsh-terminal', import.meta.url))
|
||||
const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl')
|
||||
@@ -81,6 +81,7 @@ describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls use the bas
|
||||
// The tool row is expand-gated: the settled row uses the bash layout and carries the
|
||||
// shell-family variant, and the terminal card lives in the expanded body.
|
||||
const row = page.locator('[data-tool="pwsh"]').first()
|
||||
await expandOwningTurnProcess(page, row)
|
||||
await row.waitFor({ timeout: 15_000 })
|
||||
if (await row.getAttribute('aria-expanded') !== 'true') await row.click()
|
||||
const card = page.locator('[data-terminal]').first()
|
||||
|
||||
@@ -391,6 +391,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', ()
|
||||
|
||||
it('expands to the cancellation verdict and original questions', async () => {
|
||||
onTestFailed(() => saveFailureShot(cancelledPage, 'web-e2e-question-cancelled-row'))
|
||||
await expandTurnProcesses(cancelledPage)
|
||||
const row = cancelledPage.getByRole('button', { name: 'Ask question cancelled', exact: true })
|
||||
await row.waitFor({ timeout: 15_000 })
|
||||
await row.click()
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {} from '@deepseek-ai/dsh-agent-default-model'
|
||||
import {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, writeComposerDraft } from './support.ts'
|
||||
import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, writeComposerDraft } from './support.ts'
|
||||
|
||||
const SUMMARY = `Next paragraph: ${'inspect the loaded context and pending tools '.repeat(8).trim()}`
|
||||
const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail']
|
||||
@@ -62,6 +62,7 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
|
||||
if (first === undefined || second === undefined || third === undefined) throw new Error('preview stages are incomplete')
|
||||
await first.arrived.promise
|
||||
const reasoning = page.locator('[data-variant="think"][data-state="running"]')
|
||||
await expandOwningTurnProcess(page, reasoning)
|
||||
await reasoning.waitFor()
|
||||
expect(await reasoning.getAttribute('data-preview')).toBeNull()
|
||||
|
||||
|
||||
@@ -390,7 +390,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('renders the durable direct-message then recall order', async () => {
|
||||
it('renders the direct message while retaining the following recall only in the log', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-order'))
|
||||
const group = page.getByRole('treeitem', { name: /Ungrouped/ })
|
||||
await group.waitFor({ timeout: 15_000 })
|
||||
@@ -402,12 +402,20 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
|
||||
const target = groupSection.locator('[role="treeitem"]').nth(1)
|
||||
await target.waitFor({ timeout: 15_000 })
|
||||
await target.click()
|
||||
await page.getByRole('button', { name: /^Session recall\s*Research notes$/ }).waitFor({ timeout: 15_000 })
|
||||
await page.locator('[data-chat-flow-kind="user"]').filter({ hasText: 'Research notes' }).waitFor({ timeout: 15_000 })
|
||||
const session = scaffold.ctx.sessions.get(SessionId(TARGET_SESSION_ID))
|
||||
if (session === undefined) throw new Error('reference target session is unavailable')
|
||||
const inputs = session.snapshotEvents().filter(event => event.type === 'user/message')
|
||||
expect(inputs.map(event => event.data.source.kind)).toEqual(['user', 'session-reference'])
|
||||
expect(inputs[0]?.seq).toBeLessThan(inputs[1]!.seq)
|
||||
expect(JSON.stringify(inputs[1]?.data)).toContain('<referenced-sessions>snapshot</referenced-sessions>')
|
||||
expect(await page.locator('[data-chat-flow-kind="context"]').count()).toBe(0)
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(TARGET_SESSION_ID).join('{{targetId}}')
|
||||
await compareOrRefreshGolden(ORDER_EXPECTED, snapshot, MODE)
|
||||
expect(snapshot.indexOf('Research notes what changed?')).toBeLessThan(snapshot.indexOf('Session recall Research notes'))
|
||||
expect(snapshot).toContain('Research notes what changed?')
|
||||
expect(snapshot).not.toContain('Session recall Research notes')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md', 'order.expected.md'])
|
||||
|
||||
@@ -1591,7 +1591,7 @@ export async function captureStableAria(
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a stable aria snapshot with every eligible Turn process expanded,
|
||||
* Capture stable aria with every eligible Turn process and secondary group expanded,
|
||||
* then restore the controls that were closed before the capture.
|
||||
* @param page - the page under test.
|
||||
* @param selector - the region locator selector.
|
||||
@@ -1605,13 +1605,13 @@ export async function captureExpandedTurnProcessAria(
|
||||
workspaceCwd: string,
|
||||
options: { scrollToBottom?: boolean } = {},
|
||||
): Promise<string> {
|
||||
const controls = page.locator('[data-turn-process]')
|
||||
const controls = page.locator('[data-turn-process], [data-process-activity]')
|
||||
const count = await controls.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
const opened: number[] = []
|
||||
for (let index = 0; index < count; index++) {
|
||||
const control = controls.nth(index)
|
||||
if (!await control.isVisible() || await control.getAttribute('aria-expanded') === 'true') continue
|
||||
if (!await control.isVisible() || await control.getAttribute('aria-expanded') !== 'false') continue
|
||||
await control.click()
|
||||
opened.push(index)
|
||||
}
|
||||
|
||||
@@ -63,6 +63,18 @@ const SEED_ID = 'seeded-history-web-e2e'
|
||||
|
||||
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
|
||||
|
||||
/** Trajectory visibility confirms Client receipt; Host persistence alone does not. */
|
||||
async function waitForContextInClient(page: Page, seq: number): Promise<void> {
|
||||
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
|
||||
try {
|
||||
await page.locator(`[data-trajectory-scroll] tr[data-trajectory-row-key="context%00seq%00${seq}"]`)
|
||||
.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
} finally {
|
||||
await page.getByRole('tab', { name: 'Chat', exact: true }).click()
|
||||
}
|
||||
await page.locator('[data-conversation-scroll]').waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a complete manual `/compact` lifecycle and valid compaction transaction
|
||||
* over the recorded turn's own surface. The recording stays model-authentic and
|
||||
@@ -346,7 +358,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
).toBe(true)
|
||||
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
|
||||
if (agent === undefined) throw new Error('seeded session did not attach an agent')
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
const injected = agent.session.append('user/message', createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '<system-reminder>\n'
|
||||
@@ -367,10 +379,10 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
}],
|
||||
},
|
||||
}), { surfaceOp: 'append' })
|
||||
// The header names the producer the durable source records, so the
|
||||
// reconciled instruction file is readable without expanding the row.
|
||||
await page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
await scaffold.ctx.sessions.flush(agent.session)
|
||||
expect(agent.session.snapshotEvents()).toContainEqual(injected)
|
||||
await waitForContextInClient(page, injected.seq)
|
||||
expect(await page.locator('[data-chat-flow-kind="context"]').count()).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
|
||||
@@ -397,6 +409,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
const wasExpanded = await turnProcess.getAttribute('aria-expanded') === 'true'
|
||||
if (!wasExpanded) await turnProcess.click()
|
||||
const thinking = page.locator('[data-variant="think"]').first()
|
||||
await expandOwningTurnProcess(page, thinking)
|
||||
const toggle = thinking.getByRole('button').first()
|
||||
const secondarySize = await thinking.locator('[class*="summaryText"]').evaluate(element => getComputedStyle(element).fontSize)
|
||||
await toggle.click()
|
||||
@@ -425,60 +438,15 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection'))
|
||||
const disclosure = page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true })
|
||||
expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
const collapsedIcon = disclosure.locator('svg').first()
|
||||
const collapsedIconBox = await collapsedIcon.boundingBox()
|
||||
expect(collapsedIconBox?.width).toBe(14)
|
||||
expect(collapsedIconBox?.height).toBe(14)
|
||||
|
||||
await disclosure.click()
|
||||
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
const body = page.locator('[data-context-injection-body]')
|
||||
await body.waitFor({ timeout: 5_000 })
|
||||
// The instructions form names the file it reconciled above the text, and
|
||||
// the text keeps the framing the model read rather than a cleaned excerpt.
|
||||
expect(await body.locator('[data-context-files] li').allInnerTexts()).toEqual(['AGENTS.md\nloaded'])
|
||||
expect(await body.locator('[data-context-text]').innerText()).toContain('<system-reminder>')
|
||||
const headerBox = await disclosure.boundingBox()
|
||||
const bodyBox = await body.boundingBox()
|
||||
if (headerBox === null || bodyBox === null) throw new Error('context disclosure geometry is not measurable')
|
||||
expect(headerBox.height).toBe(24)
|
||||
expect(bodyBox.x - headerBox.x).toBe(22)
|
||||
expect(bodyBox.y - headerBox.y - headerBox.height).toBe(4)
|
||||
expect(bodyBox.height).toBe(141)
|
||||
|
||||
const style = await body.evaluate((element) => {
|
||||
const computed = getComputedStyle(element)
|
||||
return {
|
||||
backgroundColor: computed.backgroundColor,
|
||||
borderRadius: computed.borderRadius,
|
||||
color: computed.color,
|
||||
fontSize: computed.fontSize,
|
||||
lineHeight: computed.lineHeight,
|
||||
padding: [
|
||||
computed.paddingTop,
|
||||
computed.paddingRight,
|
||||
computed.paddingBottom,
|
||||
computed.paddingLeft,
|
||||
],
|
||||
scrolls: element.scrollHeight > element.clientHeight,
|
||||
}
|
||||
})
|
||||
expect(style).toEqual({
|
||||
backgroundColor: 'rgb(249, 250, 251)',
|
||||
borderRadius: '8px',
|
||||
color: 'rgb(129, 133, 140)',
|
||||
fontSize: '11px',
|
||||
lineHeight: '16px',
|
||||
padding: ['10px', '16px', '12px', '12px'],
|
||||
scrolls: true,
|
||||
})
|
||||
|
||||
await disclosure.click()
|
||||
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
it.skipIf(MODE === 'record')('omits ordinary instructions from Chat while retaining their logged content', async () => {
|
||||
const session = scaffold.ctx.sessions.get(SessionId(SEED_ID))
|
||||
if (session === undefined) throw new Error('seeded session is unavailable')
|
||||
const instructions = session.snapshotEvents().filter(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'agent-instructions')
|
||||
expect(instructions).toHaveLength(1)
|
||||
expect(instructions[0]?.data).toMatchObject({ source: { form: 'instructions', changes: [{ path: 'AGENTS.md' }] } })
|
||||
expect(JSON.stringify(instructions[0]?.data)).toContain('Instruction 24: preserve the logged context contract.')
|
||||
expect(await page.locator('[data-chat-flow-kind="context"], [data-context-injection-body]').count()).toBe(0)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('restores the active turn rail mark across Chat and Trajectory', async () => {
|
||||
@@ -788,26 +756,19 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('fits short logged context without a scrollport', async () => {
|
||||
it.skipIf(MODE === 'record')('keeps short injected context out of Chat without dropping the event', async () => {
|
||||
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
|
||||
if (agent === undefined) throw new Error('seeded session did not attach an agent')
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
const injected = agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Short injected context.' }],
|
||||
source: { kind: 'fixture' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
const disclosure = page.getByRole('button', { name: 'Context injection fixture', exact: true })
|
||||
await disclosure.waitFor({ timeout: 10_000 })
|
||||
await disclosure.click()
|
||||
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
// The instructions row above stays expanded from the geometry case; the
|
||||
// opaque body is the one without a declared form.
|
||||
const body = page.locator('[data-context-injection-body]:not([data-context-form])')
|
||||
const bodyBox = await body.boundingBox()
|
||||
if (bodyBox === null) throw new Error('short context disclosure geometry is not measurable')
|
||||
expect(bodyBox.height).toBeLessThan(141)
|
||||
expect(await body.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(false)
|
||||
await scaffold.ctx.sessions.flush(agent.session)
|
||||
expect(agent.session.snapshotEvents()).toContainEqual(injected)
|
||||
await waitForContextInClient(page, injected.seq)
|
||||
expect(await page.locator('[data-chat-flow-kind="context"]').count()).toBe(0)
|
||||
expect(await page.getByText('Short injected context.', { exact: true }).count()).toBe(0)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('restores the recorded file preview in its original tab after page reload', async () => {
|
||||
|
||||
@@ -510,7 +510,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
|
||||
expect(await page.getByRole('alert').count()).toBe(0)
|
||||
expect(alive(original)).toBe(true)
|
||||
const disconnected = fileURLToPath(new URL('./expected/sidebar-terminal/disconnected.expected.md', import.meta.url))
|
||||
await compareOrRefreshGolden(disconnected, await page.getByRole('status').ariaSnapshot(), webSnapshotMode())
|
||||
await compareOrRefreshGolden(disconnected, await page.locator('[data-sidebar-terminal]').getByRole('status').ariaSnapshot(), webSnapshotMode())
|
||||
await page.screenshot({ path: `${shots}/disconnected.png`, fullPage: true })
|
||||
await reconnect.click()
|
||||
transport.reconnect()
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// the composer (issue #1470). The entered `/name args` line claims into
|
||||
// skill.invoke: the real host forwards the gesture as an ordinary user
|
||||
// prompt, injects the rendered body as instructions context named after the
|
||||
// skill, and starts a turn answered by the replay adapter. The transcript shows
|
||||
// the gesture bubble, the collapsed context-injection row, and the reply.
|
||||
// skill, and starts a turn answered by the replay adapter. Chat shows the
|
||||
// gesture bubble and reply; the Session retains the injected instructions.
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/skill-user-invoke', import.meta.url))
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
@@ -99,7 +99,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'skill-user-invoke e2e cleanup failed')
|
||||
})
|
||||
|
||||
it('claims /name args into a gesture bubble, an injection row, and a replayed answer', async () => {
|
||||
it('claims /name args into a gesture bubble, logged instructions, and a replayed answer', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke'))
|
||||
const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
|
||||
await composer.waitFor({ timeout: 15_000 })
|
||||
@@ -116,37 +116,28 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
|
||||
await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`)
|
||||
await composer.press('Enter')
|
||||
|
||||
// The gesture stays an ordinary user bubble (decorated /name token plus
|
||||
// the trailing text), ahead of the injected context.
|
||||
// The gesture stays an ordinary user bubble with the skill chip and trailing text.
|
||||
const bubble = page.locator('[data-ref-chip="skill"]').first()
|
||||
await bubble.waitFor({ timeout: 15_000 })
|
||||
expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`)
|
||||
|
||||
// The rendered body arrives as a context-injection row named after the
|
||||
// skill. Context plus the final answer contributes no summary count, so
|
||||
// the Turn uses the fallback title while the row's own disclosure remains usable.
|
||||
const injectionFlow = page.locator('[data-chat-flow-kind="context"]').filter({ hasText: SKILL_NAME })
|
||||
await injectionFlow.waitFor({ state: 'attached', timeout: 15_000 })
|
||||
await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 })
|
||||
await settled
|
||||
const process = page.getByRole('button', { name: 'Thought for a while', exact: true })
|
||||
const sessionId = await settled
|
||||
const process = page.locator('[data-turn-process]')
|
||||
await process.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
// The chip derives from the step's logged injection, so it must survive
|
||||
// every later Node rebuild of the Turn (process publication, turn close).
|
||||
expect(await bubble.count()).toBe(1)
|
||||
expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`)
|
||||
await expandOwningTurnProcess(page, injectionFlow)
|
||||
const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` })
|
||||
await injectionRow.click()
|
||||
const injectionBody = page
|
||||
.locator('[data-context-injection-body]')
|
||||
.filter({ hasText: `<skill_content name="${SKILL_NAME}">` })
|
||||
await injectionBody.waitFor({ timeout: 10_000 })
|
||||
const injected = await injectionBody.textContent()
|
||||
expect(await page.locator('[data-chat-flow-kind="context"]').count()).toBe(0)
|
||||
const session = scaffold.ctx.sessions.get(sessionId)
|
||||
if (session === undefined) throw new Error('skill invocation session is unavailable')
|
||||
const injected = session.snapshotEvents().flatMap(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'skill-invocation'
|
||||
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []).join('')
|
||||
expect(injected).toContain(`<skill_content name="${SKILL_NAME}">`)
|
||||
expect(injected).toContain('Reply with the fixture acknowledgement line.')
|
||||
expect(injected).not.toContain(ARGS_TEXT)
|
||||
await injectionRow.click()
|
||||
await process.click()
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
- tab "Trajectory"
|
||||
- text: Stream one TypeScript fence for the highlighting snapshot. {{clock}}
|
||||
- button "Copy"
|
||||
- button "Context injection runtime-context"
|
||||
- status: Deep diving...
|
||||
- button "Deep diving for {{duration}}" [disabled] [expanded]
|
||||
- text: ts
|
||||
- button "Copy"
|
||||
- code: "const first: number = 1 const second = \"two\" let tail"
|
||||
- status: Deep diving...
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands"
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import type { Browser, Page, WebSocketRoute } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { expandAssistantStream } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
|
||||
compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
@@ -196,6 +196,7 @@ describe('web e2e: composer shortcut steers directly', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let remoteSocket: WebSocketRoute | undefined
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -203,6 +204,10 @@ describe('web e2e: composer shortcut steers directly', () => {
|
||||
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.routeWebSocket('**/api/remote.mux', (route) => {
|
||||
remoteSocket = route
|
||||
route.connectToServer()
|
||||
})
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
@@ -224,6 +229,7 @@ describe('web e2e: composer shortcut steers directly', () => {
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
await page.getByRole('button', { name: 'Stop generating' }).waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => sessionEvents.some(event => event.type === 'request/context'), { timeout: 10_000 }).toBe(true)
|
||||
|
||||
await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
|
||||
await input.fill(STEER)
|
||||
@@ -235,9 +241,37 @@ describe('web e2e: composer shortcut steers directly', () => {
|
||||
await composer.waitFor({ timeout: 30_000 })
|
||||
const pendingSteering = page.locator('[data-pending-steering]').filter({ hasText: STEER })
|
||||
await pendingSteering.waitFor({ timeout: 10_000 })
|
||||
await composer.getByRole('radio', { name: 'Yes' }).click()
|
||||
await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
|
||||
await settled
|
||||
const admission = Promise.withResolvers<undefined>()
|
||||
let claimed = false
|
||||
const stopHolding = scaffold.ctx.on('agent/pre-step', async (payload, next) => {
|
||||
if (payload.messages.some(message => message.content.some(block => block.type === 'text' && block.text === STEER))) {
|
||||
claimed = true
|
||||
await admission.promise
|
||||
}
|
||||
return next()
|
||||
})
|
||||
try {
|
||||
await composer.getByRole('radio', { name: 'Yes' }).click()
|
||||
await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
|
||||
await expect.poll(() => claimed, { timeout: 10_000 }).toBe(true)
|
||||
await page.evaluate(() => new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => { resolve() }))
|
||||
}))
|
||||
expect(await page.getByText(STEER, { exact: true }).count()).toBe(1)
|
||||
expect(await page.locator('[data-submission-echo]').filter({ hasText: STEER }).count()).toBe(1)
|
||||
const socket = remoteSocket
|
||||
if (socket === undefined) throw new Error('steering page has no Remote connection')
|
||||
const warningStart = tripwire.warnings.length
|
||||
await socket.close({ code: 1012, reason: 'steering reconnect checkpoint' })
|
||||
await expect.poll(() => remoteSocket !== socket, { timeout: 10_000 }).toBe(true)
|
||||
await expect.poll(() => page.locator('[data-submission-echo]').count(), { timeout: 10_000 }).toBe(0)
|
||||
expect(await page.getByText(STEER, { exact: true }).count()).toBe(0)
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
} finally {
|
||||
admission.resolve(undefined)
|
||||
stopHolding()
|
||||
await settled
|
||||
}
|
||||
|
||||
const steerEvents = claimedMessages(sessionEvents, STEER)
|
||||
expect(steerEvents).toHaveLength(1)
|
||||
@@ -397,7 +431,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
|
||||
// The reasoning row streams independently of the steering handoff. Wait
|
||||
// for the block to settle so the mid snapshot does not race its transient
|
||||
// visually-hidden Running label while the question keeps the turn open.
|
||||
await page.locator('[data-variant="think"][data-state="ok"]').first().waitFor({ timeout: 10_000 })
|
||||
await page.locator('[data-variant="think"][data-state="ok"]').first().waitFor({ state: 'attached', timeout: 10_000 })
|
||||
const mid = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(STEER_ALL_MID, mid, MODE)
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ export function requireBuilt(name: string): unknown {
|
||||
*/
|
||||
export const ZH_BROWSER_LOCALE = 'zh-CN'
|
||||
|
||||
/** Same-day anchor for seeded event times and the Asia/Shanghai browser clock. */
|
||||
export const WEB_FIXTURE_TIME = Date.parse('2026-01-15T12:00:00+08:00')
|
||||
|
||||
/**
|
||||
* Open the standard browser-test page advertising English before client boot.
|
||||
* This keeps role locators and goldens deterministic while leaving the Host
|
||||
@@ -53,32 +56,37 @@ export async function newEnglishPage(browser: Browser, height = 1000): Promise<P
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand every currently eligible Turn-process group so a Tool-focused
|
||||
* Expand every eligible Turn process and secondary group so a Tool-focused
|
||||
* scenario can exercise the original row contract beneath product-default
|
||||
* compact Chat presentation.
|
||||
* @param page - page containing the Chat view.
|
||||
*/
|
||||
export async function expandTurnProcesses(page: Page): Promise<void> {
|
||||
const controls = page.locator('[data-turn-process]')
|
||||
const controls = page.locator('[data-turn-process], [data-process-activity]')
|
||||
await controls.first().waitFor({ state: 'visible', timeout: 10_000 })
|
||||
const count = await controls.count()
|
||||
for (let index = 0; index < count; index++) {
|
||||
const control = controls.nth(index)
|
||||
if (await control.getAttribute('aria-expanded') !== 'true') await control.click()
|
||||
if (await control.isVisible() && await control.getAttribute('aria-expanded') === 'false') await control.click()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the Turn-process group containing one possibly hidden descendant.
|
||||
* Expand the Turn process and secondary group containing a hidden descendant.
|
||||
* @param page - page containing the Chat view.
|
||||
* @param target - descendant whose owning Turn process should open.
|
||||
* @param target - descendant whose outer process disclosures should open.
|
||||
*/
|
||||
export async function expandOwningTurnProcess(page: Page, target: Locator): Promise<void> {
|
||||
if (await target.isVisible()) return
|
||||
const turn = await target.evaluate(element => element.closest<HTMLElement>('[data-chat-turn]')?.dataset.chatTurn)
|
||||
if (turn === undefined || await target.isVisible()) return
|
||||
const control = page.locator(`[data-turn-process="${turn}"]`)
|
||||
await control.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
if (await control.getAttribute('aria-expanded') !== 'true') await control.click()
|
||||
if (turn !== undefined) {
|
||||
const control = page.locator(`[data-turn-process="${turn}"]`)
|
||||
await control.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
if (await control.getAttribute('aria-expanded') === 'false') await control.click()
|
||||
}
|
||||
const group = target.locator('xpath=ancestor::*[@data-chat-group-key][1]')
|
||||
const header = group.locator('[data-process-activity]').first()
|
||||
if (await header.isVisible() && await header.getAttribute('aria-expanded') === 'false') await header.click()
|
||||
}
|
||||
|
||||
/** Fail loud on a stale checkout instead of testing yesterday's bundle. */
|
||||
|
||||
@@ -226,13 +226,15 @@ describe('web e2e: secondary Thinking Markdown', () => {
|
||||
.evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))
|
||||
expect(answerSize).toBeGreaterThan(Number.parseFloat(summaryStyle.fontSize))
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
await page.locator('[data-conversation-scroll]').evaluate((host) => {
|
||||
const groupScroll = thinking.locator('xpath=ancestor::*[@data-step-process-body][1]')
|
||||
await groupScroll.scrollIntoViewIfNeeded()
|
||||
await groupScroll.evaluate((host) => {
|
||||
const row = host.querySelector('[data-variant="think"] tbody tr:nth-child(12)')
|
||||
if (row === null) throw new Error('tall Thinking table row missing')
|
||||
host.scrollTop += row.getBoundingClientRect().top - host.getBoundingClientRect().top
|
||||
})
|
||||
await expect.poll(() => toggle.evaluate((button) => {
|
||||
const host = button.closest('[data-conversation-scroll]')
|
||||
const host = button.closest('[data-step-process-body]')
|
||||
const table = button.closest('[data-variant="think"]')?.querySelector('table')
|
||||
if (host === null || table === null || table === undefined) throw new Error('Thinking scroll context missing')
|
||||
const buttonRect = button.getBoundingClientRect()
|
||||
|
||||
@@ -13,14 +13,14 @@ import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { afterEach, describe, expect, it, onTestFailed, vi } from 'vitest'
|
||||
import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettingsFromAccountMenu, connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { openSettingsFromAccountMenu, connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/turn-tail-actions', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
|
||||
@@ -57,6 +57,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
await closing?.close().catch((error: unknown) => failures.push(error))
|
||||
if (sidecarDir !== undefined) await rm(sidecarDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
||||
sidecarDir = undefined
|
||||
vi.restoreAllMocks()
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'turn-tail-actions teardown failed')
|
||||
})
|
||||
@@ -68,6 +69,15 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
paceMs = 1,
|
||||
): Promise<void> {
|
||||
sessionEvents = []
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
if (MODE !== 'record') {
|
||||
// Playwright records real installation time; align Host time before booting it.
|
||||
await page.clock.install({ time: WEB_FIXTURE_TIME })
|
||||
const now = await page.evaluate(() => Date.now())
|
||||
const startedAt = performance.now()
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now + Math.floor(performance.now() - startedAt))
|
||||
}
|
||||
let overridePath: string | undefined
|
||||
if (buildOverride !== undefined) {
|
||||
sidecarDir = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sidecar-'))
|
||||
@@ -85,8 +95,6 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
},
|
||||
)
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
@@ -135,7 +143,10 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
// The marker IS the synchronization: the second call is provably parked,
|
||||
// so the first step's message and tool result are already durable.
|
||||
await expect.poll(() => existsSync(marker), { timeout: 20_000 }).toBe(true)
|
||||
expect(await page.locator('[data-turn-process]').count()).toBe(0)
|
||||
const runningProcess = page.locator('[data-turn-process]')
|
||||
expect(await runningProcess.count()).toBe(1)
|
||||
expect(await runningProcess.isDisabled()).toBe(true)
|
||||
expect(await runningProcess.getAttribute('aria-expanded')).toBe('true')
|
||||
await expect.poll(
|
||||
() => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
|
||||
{ timeout: 10_000 },
|
||||
@@ -258,6 +269,16 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
await expect.poll(async () => readFile(join(scaffold!.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'), { timeout: 5_000 })
|
||||
.toContain(`transcriptView: ${mode}`)
|
||||
|
||||
await process.click()
|
||||
const group = page.locator('[data-sample="bash"]').first().locator('xpath=ancestor::*[@data-chat-group-key]')
|
||||
const groupControl = group.locator('[data-process-activity]')
|
||||
expect(await groupControl.isVisible()).toBe(true)
|
||||
expect(await groupControl.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await tool.isVisible()).toBe(false)
|
||||
await groupControl.click()
|
||||
expect(await tool.isVisible()).toBe(true)
|
||||
await process.click()
|
||||
|
||||
await openSettingsFromAccountMenu(page, 'en')
|
||||
const restored = page.getByRole('dialog', { name: 'Settings' })
|
||||
await restored.getByText('Work details', { exact: true }).locator('../..')
|
||||
@@ -276,6 +297,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions-focused'))
|
||||
const { settled } = await sendPrompt()
|
||||
const tool = page.getByRole('button', { name: 'Bash Print alpha to stdout' })
|
||||
await expandOwningTurnProcess(page, page.locator('[data-sample="bash"]').first())
|
||||
await tool.waitFor({ timeout: 30_000 })
|
||||
await tool.focus()
|
||||
expect(await tool.evaluate(element => element.ownerDocument.activeElement === element)).toBe(true)
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import {
|
||||
connectFreshWorkspace, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot,
|
||||
connectFreshWorkspace, expandOwningTurnProcess, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot,
|
||||
} from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -90,6 +90,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
|
||||
await input.press('Enter')
|
||||
|
||||
const workflow = page.locator('[data-workflow-run][data-run-status="running"]')
|
||||
await expandOwningTurnProcess(page, workflow)
|
||||
await workflow.waitFor({ timeout: 30_000 })
|
||||
const disclosures = workflow.locator('[data-disclosure-row]')
|
||||
await disclosures.nth(1).waitFor({ timeout: 15_000 })
|
||||
|
||||
@@ -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/api/session-controller/README.md
|
||||
README.md: 0fd4ea07c6575a9f75167b294c6da5dab81496de
|
||||
README.zh.md: 8ce6bb6e70a8f2f766c96c2cc103e93348a4d98a
|
||||
README.md: e12ad7623eefc73c94e99cc3078e0ae78d6a969a
|
||||
README.zh.md: 5f4ceec9a8e8303cad6651dbbe7af27fc07bccec
|
||||
@@ -48,6 +48,8 @@ The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream`
|
||||
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
|
||||
|
||||
|
||||
During uninterrupted following, steering echoes stay mounted through Inbox acceptance and claim until their durable messages arrive. A replacement follow baseline withdraws earlier receipt-confirmed steering echoes as observed, using their accepted attachment references; unconfirmed submissions remain pending. Host data then supplies the pending or admitted rows. Reconnecting between claim and admission can briefly omit that bubble; withdrawal confirms acceptance, not execution or failure.
|
||||
|
||||
The user-invocable `skills/list` metadata includes the winning provider’s optional instruction-file `path`. The composer can preview that file without loading every skill body or activating a cold Agent.
|
||||
|
||||
Fork copies the exact inclusive event prefix selected by `atSeq`, including a cut inside an open turn. The child records its inherited marker before synthetic fork results and closing events. Omitting `atSeq` selects the latest completed turn and its standalone tail, stopping before the next turn or queued input; a nonexistent event is rejected. The chat action selects a completed turn.
|
||||
|
||||
@@ -48,6 +48,8 @@ Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session
|
||||
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的提示词失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。
|
||||
|
||||
|
||||
正常在线时,steering 回显在 Inbox 接受与领取期间保持挂载,直到持久消息到达。新的 follow 基线会将此前已有接收回执的 steering 回显按 observed 撤去,使用已接受的附件引用完成结算;尚未确认接收的提交继续保留。待处理或已入档的消息随后由 Host 数据呈现。在领取与入档之间重连时,气泡可能短暂消失;撤去回显只确认接收,不代表执行或失败。
|
||||
|
||||
面向用户调用的 `skills/list` 元数据包含胜出提供方可选的指令文件 `path`。输入框可据此预览文件,无需加载每个 skill 的正文或激活冷态 Agent。
|
||||
|
||||
Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在开放轮次内截取。子会话在合成的 fork 结果和结束事件之前记录继承标记。省略 `atSeq` 时选择最近已结束轮次及其独立尾部,在下一轮次或排队输入之前停止;不存在的事件会被拒绝。聊天操作选择已结束轮次。
|
||||
|
||||
@@ -6,6 +6,7 @@ import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
|
||||
import { SessionLogOffset, SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { SessionEventStream } from '../transport.ts'
|
||||
import type { SessionJournalChange } from '../transport.ts'
|
||||
@@ -25,7 +26,7 @@ import type {
|
||||
} from '../contract/snapshot.ts'
|
||||
import { MutableSessionEventSource } from '../contract/events.ts'
|
||||
import type {
|
||||
SessionEventLikeEntry, SessionLiveEventEntry,
|
||||
SessionEventLike, SessionEventLikeEntry, SessionLiveEventEntry,
|
||||
} from '../contract/events.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
@@ -124,6 +125,13 @@ export class Session implements SessionFace {
|
||||
/** Per-echo settlement state; `retiring` latches the first observation so a
|
||||
* Inbox projection and its durable event cannot both retire one echo. */
|
||||
private readonly submissionSettlements = new Map<SessionRequestId, {
|
||||
readonly placement: PendingSubmission['placement']
|
||||
/** Latest received next-step position; null means claimed but not yet admitted. */
|
||||
receipt?: {
|
||||
readonly seq: number
|
||||
readonly index: number | null
|
||||
readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[]
|
||||
}
|
||||
readonly onRetire?: ((retirement: PendingSubmissionRetirement) => void) | undefined
|
||||
retiring: boolean
|
||||
}>()
|
||||
@@ -209,16 +217,15 @@ export class Session implements SessionFace {
|
||||
*/
|
||||
beginSubmission(input: BeginSubmissionInput): SubmissionHandle {
|
||||
const requestId = randomUUID() as SessionRequestId
|
||||
const placement = this.running ? input.mode === 'steer' ? 'steering' : 'queued' : 'transcript'
|
||||
this.pendingSubmissions = [...this.pendingSubmissions, {
|
||||
requestId,
|
||||
placement: this.running
|
||||
? input.mode === 'steer' ? 'steering' : 'queued'
|
||||
: 'transcript',
|
||||
placement,
|
||||
time: Date.now(),
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
}]
|
||||
this.submissionSettlements.set(requestId, { onRetire: input.onRetire, retiring: false })
|
||||
this.submissionSettlements.set(requestId, { placement, onRetire: input.onRetire, retiring: false })
|
||||
// The blank → engaging edge flips here, ahead of prompt(): the composer
|
||||
// docks and the echo renders on the click's own frame.
|
||||
this.promptAttempted = true
|
||||
@@ -665,7 +672,20 @@ export class Session implements SessionFace {
|
||||
if (visible.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
|
||||
if (projections !== undefined) this.projections.seed(projections)
|
||||
this.eventSource.replace(visible, hasMore)
|
||||
// A new follow baseline replaces optimistic steering with Host-owned rows.
|
||||
// Receipt-backed inputs are accepted, not failed, even if their history is outside this window.
|
||||
if (projections !== undefined) {
|
||||
for (const [requestId, { receipt }] of this.submissionSettlements) {
|
||||
if (receipt !== undefined && receipt.seq <= projections.asOfSeq) {
|
||||
this.scheduleObservedRetirement(requestId, receipt.attachments)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const entry of visible) this.observeSubmissionEvent(entry.event)
|
||||
if (projections !== undefined) {
|
||||
const inbox = projections.values.inbox as InboxState | undefined
|
||||
this.observeSteeringInsertions(inbox?.['next-step'] ?? [], 0, projections.asOfSeq)
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
@@ -724,36 +744,59 @@ export class Session implements SessionFace {
|
||||
}
|
||||
|
||||
/** Observe durable acceptance even when insertion and claim share one projection notification. */
|
||||
private observeSubmissionEvent(event: { readonly type: string; readonly data?: unknown }): void {
|
||||
private observeSubmissionEvent(event: SessionEventLike): void {
|
||||
if (this.submissionSettlements.size === 0) return
|
||||
if (event.type === 'agent/inbox/spliced') {
|
||||
const splice = event.data as { readonly inserted?: unknown } | undefined
|
||||
if (Array.isArray(splice?.inserted)) {
|
||||
for (const message of splice.inserted) this.observeSubmissionEvent({ type: 'user/message', data: message })
|
||||
const { target, start, removedCount = 0, inserted, outcome } = event.data
|
||||
if (target === 'next-step') {
|
||||
for (const [requestId, settlement] of this.submissionSettlements) {
|
||||
const receipt = settlement.receipt
|
||||
if (receipt === undefined || receipt.index === null || receipt.seq >= event.seq) continue
|
||||
const removed = receipt.index >= start && receipt.index < start + removedCount
|
||||
if (removed && outcome === 'canceled') this.retireFailedSubmission(requestId)
|
||||
else settlement.receipt = {
|
||||
...receipt,
|
||||
seq: event.seq,
|
||||
index: removed ? null : receipt.index < start ? receipt.index : receipt.index + inserted.length - removedCount,
|
||||
}
|
||||
}
|
||||
this.observeSteeringInsertions(inserted, start, event.seq)
|
||||
}
|
||||
for (const message of inserted) this.observeSubmissionMessage(message, false)
|
||||
return
|
||||
}
|
||||
if (event.type === 'request/context' || event.type === 'turn/end') {
|
||||
for (const [requestId, settlement] of this.submissionSettlements) {
|
||||
if (settlement.receipt?.index === null && settlement.receipt.seq < event.seq) this.retireFailedSubmission(requestId)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type !== 'user/message') return
|
||||
// Structural read: window entries may be compact history records, so the
|
||||
// fields are narrowed rather than trusted (same posture as Conversation
|
||||
// assembly matchers).
|
||||
const data = event.data as { readonly source?: unknown; readonly content?: unknown } | undefined
|
||||
const source = data?.source as { readonly kind?: unknown; readonly rpcId?: unknown } | undefined
|
||||
if (source?.kind !== 'user' || typeof source.rpcId !== 'string') return
|
||||
this.scheduleObservedRetirement(source.rpcId as SessionRequestId, attachmentRefsIn(data?.content))
|
||||
if (event.type === 'user/message') this.observeSubmissionMessage(event.data, true)
|
||||
}
|
||||
|
||||
/** Retire local echoes when their accepted messages appear in the durable Inbox projection. */
|
||||
private observeSteeringInsertions(messages: readonly UserMessage[], start: number, seq: number): void {
|
||||
for (const [index, message] of messages.entries()) {
|
||||
const source = message.source
|
||||
if (source.kind !== 'user' || !('rpcId' in source)) continue
|
||||
const settlement = this.submissionSettlements.get(source.rpcId)
|
||||
if (settlement?.placement !== 'steering' || settlement.retiring || (settlement.receipt?.seq ?? -1) > seq) continue
|
||||
settlement.receipt = { seq, index: start + index, attachments: attachmentRefsIn(message.content) }
|
||||
}
|
||||
}
|
||||
|
||||
private observeSubmissionMessage(message: UserMessage, admitted: boolean): void {
|
||||
const source = message.source
|
||||
if (source.kind !== 'user' || !('rpcId' in source)) return
|
||||
if (!admitted && this.submissionSettlements.get(source.rpcId)?.placement === 'steering') return
|
||||
this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content))
|
||||
}
|
||||
|
||||
/** Retire non-steering echoes when the Inbox accepts their queue occurrences. */
|
||||
private observeSubmissionInbox(): void {
|
||||
if (this.submissionSettlements.size === 0) return
|
||||
const inbox = this.projections.get('inbox') as InboxState | undefined
|
||||
if (inbox === undefined) return
|
||||
for (const message of [...inbox['next-turn'], ...inbox['next-step']]) {
|
||||
const source = message.source
|
||||
if (source.kind === 'user' && 'rpcId' in source) {
|
||||
this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content))
|
||||
}
|
||||
}
|
||||
for (const message of [...inbox['next-turn'], ...inbox['next-step']]) this.observeSubmissionMessage(message, false)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
|
||||
import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts'
|
||||
import type { SessionRequestId } from '../src/types.ts'
|
||||
import { ev, historyValue } from './event-script.client.ts'
|
||||
import { sessionBench } from './remote/bench.client.ts'
|
||||
import {
|
||||
FOLLOW, err, fileRef, followScript, history, imageRef, pushEvent,
|
||||
@@ -193,6 +194,77 @@ describe('observed retirement', () => {
|
||||
expect(onRetire).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: refs })
|
||||
})
|
||||
|
||||
it('keeps a running steer echo through accepted and claimed Inbox states until transcript admission', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
await session.open()
|
||||
session.handleRunning(true)
|
||||
const onRetire = vi.fn()
|
||||
const handle = session.beginSubmission({ mode: 'steer', text: 'accepted', attachments: [], onRetire })
|
||||
const refs = [imageRef('steer-image')]
|
||||
const message = queuedItem(handle.requestId, refs)
|
||||
const echo = session.getSnapshot().pendingSubmissions[0]
|
||||
session.projections.apply('inbox', { 'next-turn': [], 'next-step': [message] }, SessionSeq(0))
|
||||
await settleFrames()
|
||||
expect(session.getSnapshot().pendingSubmissions).toEqual([echo])
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1,
|
||||
data: { target: 'next-step', start: 0, inserted: [message] },
|
||||
})
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 2,
|
||||
data: { target: 'next-step', start: 0, removedCount: 1, inserted: [] },
|
||||
})
|
||||
session.projections.apply('inbox', { 'next-turn': [], 'next-step': [] }, SessionSeq(1))
|
||||
await settleFrames()
|
||||
expect(session.getSnapshot().pendingSubmissions).toEqual([echo])
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
await pushEvent(mock, {
|
||||
type: 'user/message', seq: SessionSeq(2), time: 3, surfaceOp: 'append', data: message,
|
||||
})
|
||||
await settleFrames()
|
||||
expect(session.getSnapshot().pendingSubmissions).toEqual([])
|
||||
expect(onRetire).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: refs })
|
||||
})
|
||||
|
||||
it('retires canceled or unadmitted steering without removing still-pending input', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
await session.open()
|
||||
session.handleRunning(true)
|
||||
const canceled = vi.fn()
|
||||
const rejected = vi.fn()
|
||||
const first = session.beginSubmission({ mode: 'steer', text: 'cancel', attachments: [], onRetire: canceled })
|
||||
const second = session.beginSubmission({ mode: 'steer', text: 'reject', attachments: [], onRetire: rejected })
|
||||
const unclaimed = session.beginSubmission({ mode: 'steer', text: 'later', attachments: [] })
|
||||
const foreign = createUserMessage({ source: { kind: 'user' }, content: [] })
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1,
|
||||
data: { target: 'next-step', start: 0, inserted: [queuedItem(first.requestId), queuedItem(second.requestId)] },
|
||||
})
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 2,
|
||||
data: { target: 'next-step', start: 0, inserted: [foreign] },
|
||||
})
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(2), time: 3,
|
||||
data: { target: 'next-step', start: 1, removedCount: 1, inserted: [], outcome: 'canceled' },
|
||||
})
|
||||
expect(canceled).toHaveBeenCalledExactlyOnceWith({ reason: 'failed' })
|
||||
expect(rejected).not.toHaveBeenCalled()
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(3), time: 4,
|
||||
data: { target: 'next-step', start: 0, removedCount: 2, inserted: [] },
|
||||
})
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(4), time: 5,
|
||||
data: { target: 'next-step', start: 0, inserted: [queuedItem(unclaimed.requestId)] },
|
||||
})
|
||||
await pushEvent(mock, {
|
||||
type: 'turn/end', seq: SessionSeq(5), time: 6, data: { turn: 1, reason: { kind: 'blocked' } },
|
||||
})
|
||||
expect(rejected).toHaveBeenCalledExactlyOnceWith({ reason: 'failed' })
|
||||
expect(session.getSnapshot().pendingSubmissions.map(echo => echo.requestId)).toEqual([unclaimed.requestId])
|
||||
})
|
||||
|
||||
it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
const retirements: PendingSubmissionRetirement[] = []
|
||||
@@ -244,6 +316,69 @@ describe('observed retirement', () => {
|
||||
expect(session.getSnapshot().pendingSubmissions).toEqual([])
|
||||
})
|
||||
|
||||
for (const pending of [false, true]) {
|
||||
it(`retires confirmed steering on resync with the Host input ${pending ? 'still pending' : 'outside the history window'}`, async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
await session.open()
|
||||
session.handleRunning(true)
|
||||
const onRetire = vi.fn()
|
||||
const handle = session.beginSubmission({ mode: 'steer', text: 'accepted', attachments: [], onRetire })
|
||||
const refs = [imageRef('steer-image'), fileRef('steer-file')]
|
||||
const message = queuedItem(handle.requestId, refs)
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1,
|
||||
data: { target: 'next-step', start: 0, inserted: [message] },
|
||||
})
|
||||
const unconfirmed = session.beginSubmission({ mode: 'steer', text: 'not accepted yet', attachments: [] })
|
||||
const inbox = { 'next-turn': [], 'next-step': pending ? [message] : [] }
|
||||
const replacement = {
|
||||
...historyValue([ev.turnEnd(SessionSeq(10), 2)], true),
|
||||
projections: { asOfSeq: 10, values: { inbox } },
|
||||
}
|
||||
mock.stream(FOLLOW, followScript({ ok: true, value: replacement }))
|
||||
await session.resync()
|
||||
await settleFrames()
|
||||
|
||||
expect(session.getSnapshot().pendingSubmissions.map(item => item.requestId)).toEqual([unconfirmed.requestId])
|
||||
expect(session.projections.get('inbox')).toEqual(inbox)
|
||||
expect(onRetire).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: refs })
|
||||
await pushEvent(mock, ev.turnEnd(SessionSeq(11), 3))
|
||||
expect(onRetire).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
}
|
||||
|
||||
it('withdraws a confirmed optimistic steer when resync lands between claim and admission', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
await session.open()
|
||||
session.handleRunning(true)
|
||||
const onRetire = vi.fn()
|
||||
const handle = session.beginSubmission({ mode: 'steer', text: 'accepted', attachments: [], onRetire })
|
||||
const refs = [imageRef('claimed-image')]
|
||||
const message = queuedItem(handle.requestId, refs)
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1,
|
||||
data: { target: 'next-step', start: 0, inserted: [message] },
|
||||
})
|
||||
const replacement = {
|
||||
...historyValue([{
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 2,
|
||||
data: { target: 'next-step', start: 0, removedCount: 1, inserted: [] },
|
||||
}], true),
|
||||
projections: { asOfSeq: 1, values: { inbox: { 'next-turn': [], 'next-step': [] } } },
|
||||
}
|
||||
mock.stream(FOLLOW, followScript({ ok: true, value: replacement }))
|
||||
await session.resync()
|
||||
await settleFrames()
|
||||
expect(session.getSnapshot().pendingSubmissions).toEqual([])
|
||||
expect(onRetire).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: refs })
|
||||
|
||||
const admitted = promptEvent(SessionSeq(2), handle.requestId, refs)
|
||||
await pushEvent(mock, admitted)
|
||||
await settleFrames()
|
||||
expect(session.eventSource.getSnapshot().entries.at(-1)?.event).toEqual(admitted)
|
||||
expect(onRetire).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('the first observation wins: a later prompt failure cannot re-retire an observed echo', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
await session.open()
|
||||
|
||||
@@ -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/locale/README.md
|
||||
README.md: c9c6c86201d7618178cdcfe50790747ebd75fde4
|
||||
README.zh.md: f97265b4a9ff58955f4b8720091d7460f2a7d397
|
||||
README.md: a21f61dd931204f990e6865419ac19476c957ff4
|
||||
README.zh.md: c93b555f96e459ddad56faaf46d267cf319218ad
|
||||
@@ -89,6 +89,8 @@ The provisional locale comes from the browser (`navigator.languages` matched by
|
||||
|
||||
### Dictionary lookup
|
||||
|
||||
Document language synchronization writes `<html lang>` only when its value changes; dictionary-only revisions leave the attribute untouched.
|
||||
|
||||
The typed object form requires complete dictionaries for both built-in locales. The per-locale form lets language packs register each namespace independently. For each key, lookup walks the active language's declared fallback chain in the requested namespace, repeats that chain in `common`, then displays the key itself. Bound translate functions retain stable identity per namespace so they can ride inject surfaces without breaking memoization.
|
||||
|
||||
### Source map
|
||||
|
||||
@@ -89,6 +89,8 @@ Host 通过 settings 服务为 loopback 页面持久化偏好。Client 会刻意
|
||||
|
||||
### 字典查找
|
||||
|
||||
文档语言同步只在值变化时写入 `<html lang>`;仅更新字典的 revision 不改动该属性。
|
||||
|
||||
带类型的对象形式要求两个内置 locale 都有完整字典;逐 locale 形式允许语言包独立注册每个命名空间。逐键查找会先在请求命名空间中沿生效语言声明的 fallback 链查找,再在 `common` 中重复该链,最后显示键本身。绑定的翻译函数按命名空间保持稳定身份,因此可通过 inject 机制传递,且不会破坏 memoization。
|
||||
|
||||
### 源码地图
|
||||
|
||||
@@ -148,7 +148,8 @@ function normalizeLanguage(input: LanguageRegistration): Readonly<LanguageRegist
|
||||
function syncDocumentLanguage(snapshot: LocaleSnapshot): void {
|
||||
// Non-browser runs (node boots of the client tree) have no document.
|
||||
if (typeof document === 'undefined') return
|
||||
document.documentElement.lang = snapshot.active === 'zh' ? 'zh-CN' : snapshot.active
|
||||
const language = snapshot.active === 'zh' ? 'zh-CN' : snapshot.active
|
||||
if (document.documentElement.lang !== language) document.documentElement.lang = language
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -97,4 +97,21 @@ describe('document language', () => {
|
||||
locale.setLocale('pt-BR')
|
||||
expect(langOf()).toBe('pt-BR')
|
||||
})
|
||||
|
||||
it('does not rewrite the document language for catalog changes with the same active locale', async () => {
|
||||
const { locale } = await bench()
|
||||
const observer = new MutationObserver(() => {})
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
|
||||
try {
|
||||
const remove = locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' })
|
||||
expect(observer.takeRecords()).toHaveLength(0)
|
||||
remove()
|
||||
expect(observer.takeRecords()).toHaveLength(0)
|
||||
locale.setLocale('en')
|
||||
expect(observer.takeRecords()).toHaveLength(1)
|
||||
expect(langOf()).toBe('en')
|
||||
} finally {
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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: 0fd407f33b1a7cba5a1f2612411d9ec333679c9c
|
||||
README.zh.md: e9d5005851f57d632ca4ab7835d32ce14105d3a6
|
||||
README.md: 14a5260e23640d58c73779cff76bd27853bf4685
|
||||
README.zh.md: 94e1442c5a1afbe3e57df8e2ac2e22181becf253
|
||||
@@ -38,7 +38,9 @@ Settings → General → Open chat links in selects the destination for ordinary
|
||||
<a id="system-prompt-row"></a>
|
||||
## Hidden Chat rows
|
||||
|
||||
Chat omits system-prompt and `permission` command rows in every work-details mode. The filter changes neither recorded Session events nor Trajectory inspection. Ordinary Context injection and other command rows remain in Chat; Context injection can fold with the owning Turn's process content.
|
||||
Chat omits system-prompt, ordinary Context injection, and `permission` command rows in every work-details mode. The filter changes neither recorded Session events nor Trajectory inspection. Non-human Turn triggers remain independent notices; other command rows remain in Chat.
|
||||
|
||||
When an Assistant attempt retires without a visible message, Chat hides its already-published Node instead of removing its key. A retry in the same Step reuses that key when visible content returns. This also applies when the loaded window lacks the Step start.
|
||||
|
||||
<a id="command-and-failure-rows"></a>
|
||||
## Command and failure rows
|
||||
@@ -56,42 +58,40 @@ Settings → General → Performance & usage stores `ui-chat.performanceUsage` a
|
||||
|
||||
On non-loopback browsers, the preference remains process-local because the settings scope cannot persist writes. Explicit selections update every consumer immediately; accepted Host settings reconcile the live value on loopback browsers.
|
||||
|
||||
Preference menus restore focus to their trigger without scrolling before publishing a new selection.
|
||||
|
||||
<a id="completed-turn-footer"></a>
|
||||
## Completed-turn footer
|
||||
|
||||
The completed-turn action footer starts 20px below the preceding prose or extension content.
|
||||
Artifact extensions can subscribe to one Turn and Node kind through `ChatNodeStore.turnDataSource`. The source includes hidden Nodes and exposes their business data in anchor order. Membership updates incrementally; only observed collections materialize ordered arrays, and unrelated Turns or kinds do not notify them.
|
||||
|
||||
The completed-turn action footer follows the recorded Turn end. Its action row starts 20px below preceding prose or extension content. Actions remain visible only on the latest Turn when its final visible content is a reply; other endings and historical Turns reveal actions on hover or keyboard focus. Devices without hover keep actions visible.
|
||||
|
||||
-----
|
||||
|
||||
<a id="turn-process-folding"></a>
|
||||
## Turn Process Folding
|
||||
|
||||
Each reasoning row starts collapsed, including during streaming and in reasoning-only replies. Clicking the row opens or closes its complete Markdown; incoming answer text, Tool calls, and stream completion preserve that choice. Expanded reasoning uses compact secondary typography: headings add bold weight without changing text size, line height, or color. While streaming, all modes preview the latest paragraph whose first line ends with a newline. Further text in that paragraph does not change the preview; an unfinished single line has no preview. One or more blank lines separate paragraphs. The one-line preview fades at the right edge. After settlement, Compact hides the preview, while Detailed and Expanded show the full text's first line.
|
||||
During uninterrupted following, local steering echoes remain mounted through Inbox acceptance and claim until the durable message arrives, without triggering tail following twice. Pending inputs follow Inbox order across clients, using matching local echoes in place. After reconnect, Host-owned rows replace receipt-confirmed local echoes; a claim awaiting admission may briefly have no bubble.
|
||||
|
||||
Reasoning rows retain the disclosure's callback, icon, and unchanged content references. Each reasoning row selects its preview visibility from the display policy; mode changes toggle CSS display without unmounting the collapsed summary or updating its parent Assistant renderer. Full Markdown elements are created only while expanded.
|
||||
|
||||
Settings → General → Work details stores `ui-chat.transcriptView` as `compact` (default), `detailed`, or `expanded`. A saved legacy `normal` value reads as `detailed` without being written back; it is not offered as an option.
|
||||
|
||||
| Behavior | Compact | Detailed | Expanded |
|
||||
|---|---|---|---|
|
||||
| Settled reasoning preview | Think title only | First line | First line |
|
||||
| Individual reasoning and tool bodies | Manual expansion | Manual expansion | Manual expansion |
|
||||
| Eligible completed-Turn process | Folded by default | Folded by default | Folded by default |
|
||||
|
||||
While a Turn is open, its process rows remain in the transcript; individual reasoning and tool disclosures retain their own expansion state. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block. If that Turn's `turn/start` is loaded, preceding Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows fold by default. Folding eligibility is per Turn: a complete Turn can fold while Load earlier remains available, and a Turn whose start is missing keeps its process content visible until that start arrives. A closed Turn with no final answer also keeps all process evidence visible.
|
||||
|
||||
The control reports Turn-wide durable counts for non-subagent Tool calls, reply-bearing Assistant messages before the final answer, and subagent delegation calls. Zero-valued segments are omitted; the Tool and subagent figures are mutually exclusive, and Context injection contributes no count. When all three counts are zero, the process still folds and the control reads `Thought for a while`. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, error, max-token, and turn-tail rows stay outside. A newly available process control preserves existing row order, with opening human input before the control and process rows.
|
||||
|
||||
Stable Chat Node Seats keep every renderer mounted, hidden members add no flow spacing, and a closed control sits 8px above its answer only when no independent input intervenes. Completion collapse does not depend on tail-follow position, so a reader above the tail may see the transcript reflow. An automatic collapse that would hide keyboard focus keeps the process open and leaves focus in place; a manual close focuses the process control before hiding its members. The session-scoped store records only manually expanded Turn-and-answer-Step generations; a different answer generation starts collapsed. Switching work-details modes preserves manual expansion.
|
||||
Work-details modes control process-group display and reasoning previews; eligible completed Turns fold their process without hiding the final answer. The [business-rule reference](src/client/conversation-nodes/README.md#display-modes) contains the mode table, title behavior, whole-Turn eligibility, clocks, and disclosure resets.
|
||||
|
||||
-----
|
||||
|
||||
<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.
|
||||
Chat registers its process Group Definition through `uiConversation.groups`. React renders the mixed `node`/`group` root sequence through stable Group and Node seats; group headers subscribe to data separately from member arrays. Settled group titles remain independent of the live-detail preference; only running titles update when that preference changes. [Process-group business rules](src/client/conversation-nodes/README.md#process-grouping) define segmentation and activity summaries.
|
||||
|
||||
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.
|
||||
`groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector.
|
||||
|
||||
The process group uses a stable `div` layout box, a scroll body, and an uncapped content box that reports growth inside the body. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception. CSS variables do not belong in the Group Definition.
|
||||
|
||||
Scroll-edge fades initialize when `ResizeObserver` reports the open group's layout; opening the group performs no immediate scroll-dimension read in a layout effect.
|
||||
|
||||
Each group owns local `useDisclosure` state that survives mode changes while its component stays mounted.
|
||||
|
||||
The Chat-node slot injects a reset-bound `useDisclosure` Hook for reasoning and tools. Intermediate renderers forward it without subscribing; each invocation owns independent open state. Source callbacks retain their receiver and stable identity. When an enclosing Turn actually hides a process member, its seat resets those disclosures without replacing component keys or changing the Hook reference. Display-mode changes preserve their open state.
|
||||
|
||||
-----
|
||||
|
||||
@@ -107,7 +107,11 @@ While the pointer is outside the rail, automatic follow keeps the rail still whe
|
||||
<details>
|
||||
<summary>Scroll implementation — click to expand</summary>
|
||||
|
||||
`useChatViewport` owns turn-aware DOM reads, clamped writes, and native events. `useChatReading` owns follow policy, sampled reader input, and semantic memory; `useChatNavigation` owns turn jumps and history-prepend preservation. `useChatScroll` coordinates their committed inputs. Explicit navigation carries its measured landing into reading policy, so it does not rediscover the known target with a hit test.
|
||||
`useChatViewport` owns turn-aware DOM reads, clamped writes, native events, and one retained paging anchor. For Load older, Node and Group seats mark eligible anchors from their existing disclosure state. The viewport selects the first nonempty, unhidden marker in transcript order without hit testing or geometry-based search, then measures that element and its scroll containers. It compensates the anchor's capped group first, then gives the remaining displacement to the transcript scrollport. Commits and later content resizes reuse that anchor; a remounted row is resolved by the same semantic key. Compensation stays within the actual scroll ranges without adding bottom space.
|
||||
|
||||
`useChatReading` owns follow policy, sampled reader input, and semantic memory; `useChatNavigation` owns turn jumps and requests preservation from the viewport. Reading gestures release the paging anchor, but composer clicks, typing, and non-scrolling keys retain it; while a page is still loading, `scrollend` captures the reader's new position. `useChatScroll` coordinates their committed inputs. Explicit navigation carries its measured landing into reading policy, so it does not rediscover the known target with a hit test.
|
||||
|
||||
Active-Turn highlighting is approximate: `readVisibleTurn` binary-searches the content column's direct Node/Group boxes and retains the preceding candidate in gaps. It neither hit-tests the document nor searches Group members or all Turn markers. Empty Seats retain zero-height in-flow boxes so outer positions remain ordered without extra spacing. This lookup does not change semantic position capture or paging compensation.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -126,7 +130,6 @@ None; Chat presentation does not assemble or mutate provider requests.
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **Secondary process grouping is not implemented** — Detailed and Expanded currently display the same Chat content. Expanded retains whole-Turn folding and does not automatically open individual reasoning or tool bodies.
|
||||
|
||||
- **Developer messages are not displayed** — presentation is intentionally deferred; encountering `developer/message` throws instead of rendering a fallback row.
|
||||
|
||||
|
||||
@@ -38,7 +38,9 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H
|
||||
<a id="system-prompt-row"></a>
|
||||
## Chat 隐藏的行
|
||||
|
||||
Chat 在所有工作过程展示模式下都不显示系统提示词行和 `permission` 命令行。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。普通上下文注入和其他命令行仍保留在 Chat 中;上下文注入可随所属轮次的过程内容一起收起。
|
||||
Chat 在所有工作过程展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。
|
||||
|
||||
Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,不移除其 key。同一 Step 的重试再次产生可见内容时,复用该 key。已加载窗口缺少 Step 起点时也遵循此规则。
|
||||
|
||||
<a id="command-and-failure-rows"></a>
|
||||
## 指令与失败行
|
||||
@@ -56,42 +58,40 @@ Chat 在所有工作过程展示模式下都不显示系统提示词行和 `perm
|
||||
|
||||
在非回环地址浏览器中,设置作用域无法持久化写入,因此该偏好仅在当前进程内生效。明确选择会立即更新所有使用方;回环地址浏览器收到 Host 已接受的设置后会同步当前值。
|
||||
|
||||
偏好菜单在发布新选择之前,先将焦点还给触发按钮,且不引起滚动。
|
||||
|
||||
<a id="completed-turn-footer"></a>
|
||||
## 已完成轮次的页脚
|
||||
|
||||
已完成轮次的操作页脚位于前方正文或扩展内容下方 20px。
|
||||
产物扩展可通过 `ChatNodeStore.turnDataSource` 订阅一个 Turn 中指定类型的节点数据。来源包含隐藏节点,并按锚点顺序提供其业务数据。成员关系增量更新;只有被订阅的集合才生成有序数组,其他 Turn 或类型的更新不通知该集合。
|
||||
|
||||
已完成轮次的操作页脚位于记录的轮次结束之后。操作行与前方正文或扩展内容相隔 20px。只有最新轮次且最后可见内容为回复时,操作常显;其他结尾及历史轮次在悬停或键盘聚焦时显示操作。不支持悬停的设备始终显示操作。
|
||||
|
||||
-----
|
||||
|
||||
<a id="turn-process-folding"></a>
|
||||
## 轮次过程折叠
|
||||
|
||||
每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。流式输出期间,三档都预览最近一个首行以换行符结束的段落;该段落后续文本不会改变预览,未写完的单行不显示预览。一个或多个空行分隔段落。预览保持单行,右侧渐隐。结算后,「简洁」隐藏预览,「详细」和「完全展开」显示全文首行。
|
||||
正常在线时,本地 steering 回显在 Inbox 接受与领取期间保持挂载,直到持久消息到达,不会重复触发跟随底部。跨客户端的待处理输入遵循 Inbox 顺序,在匹配位置使用本地回显。重连后,Host 消息替代已有接收回执的本地回显;已领取但尚未入档时,气泡可能短暂消失。
|
||||
|
||||
思考行保留折叠控件的回调、图标和未变化内容的引用。每个思考行从展示策略中选择预览可见性;模式切换只改变 CSS 显示状态,不卸载折叠摘要,也不更新其父级 Assistant 渲染器。完整 Markdown 元素仅在展开时创建。
|
||||
|
||||
「设置 → 通用设置 → 工作过程展示」将 `ui-chat.transcriptView` 保存为 `compact`(默认)、`detailed` 或 `expanded`。已保存的旧值 `normal` 读取为 `detailed`,不自动回写,也不再作为选项提供。
|
||||
|
||||
| 行为 | 简洁 | 详细 | 完全展开 |
|
||||
|---|---|---|---|
|
||||
| 已完成思考的预览 | 只显示「思考」标题 | 首行 | 首行 |
|
||||
| 单个思考与工具正文 | 手动展开 | 手动展开 | 手动展开 |
|
||||
| 符合条件的已完成轮次过程 | 默认收起 | 默认收起 | 默认收起 |
|
||||
|
||||
轮次打开期间,过程行保留在 transcript 中,单个思考与工具控件各自保留开合状态。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界。若该轮次的 `turn/start` 已加载,边界之前的上下文注入、思考、较早 Assistant 内容、工具行与重试行默认收起。折叠资格逐轮判断:即使仍可「加载更早」,完整轮次也能收起;缺少起点的轮次则保持过程内容可见,直到该起点加载。关闭时没有最终答案的轮次也保留全部过程证据。
|
||||
|
||||
控件展示覆盖整个轮次的非 subagent 工具调用数、最终答案之前带回复内容的 Assistant 消息数和 subagent 委派数。值为 0 的分段省略;工具调用与 subagent 两项互斥,上下文注入不增加计数。三项全为 0 时过程仍会收起,控件标题显示「已思考」(英文为 `Thought for a while`)。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、错误、最大 token 与 turn-tail 行留在过程之外。新的过程控件保留既有行的相对顺序,开场人工输入位于控件和过程行之前。
|
||||
|
||||
稳定 Chat Node Seat 会让每个 renderer 保持挂载,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。会话作用域存储只记录用户手动展开的「轮次 + 正文步骤」generation;不同正文 generation 默认收起。切换工作过程展示模式会保留手动展开状态。
|
||||
工作过程展示模式控制过程组显示与推理预览;符合条件的已完成轮次折叠过程,但不隐藏最终答案。[业务规则明细](src/client/conversation-nodes/README.zh.md#display-modes) 统一说明模式表、组头行为、整轮折叠资格、时钟与开合重置。
|
||||
|
||||
-----
|
||||
|
||||
<a id="grouped-rendering"></a>
|
||||
## 分组渲染
|
||||
|
||||
Chat 可以把可选的 Conversation Group Definition 输出渲染为混合 `node`/`group` 根列表。组容器只订阅成员数组,每个成员保留既有的按键 Node 来源与渲染器。`groupPart` 作为业务拥有的部分选择器传给渲染器,不同部分具有独立的 DOM 锚点用于恢复阅读位置;轮次导航仍可使用原 Node key,落到它的第一个可见部分。展示模式不选择根分支,也不改变成员父级。本包尚未注册过程 Group Definition,默认文本记录仍未分组。
|
||||
Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过稳定的 Group 与 Node 容器渲染混合 `node`/`group` 根序列,组头数据与成员数组分别订阅。已结束组的标题独立于实时详情偏好,只有运行中的标题在该偏好变化时更新。[过程分组业务规则](src/client/conversation-nodes/README.zh.md#process-grouping) 定义切分方式与活动摘要。
|
||||
|
||||
组容器使用 `div` 与 `display: contents`,保留 DOM 父级但不产生布局盒子。CSS 继承仍然可用,子级和兄弟选择器仍遵循 DOM 树。原有直接子级间距选择器不会匹配组内成员。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例,并拥有需要测量的正文或滚动容器。CSS 变量不属于 Group Definition。
|
||||
`groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。
|
||||
|
||||
过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。CSS 变量不属于 Group Definition。
|
||||
|
||||
滚动边缘渐隐在 `ResizeObserver` 报告已展开组的布局后初始化;展开组时不会在 layout effect 中立即读取滚动尺寸。
|
||||
|
||||
每个组拥有本地 `useDisclosure` 状态,组件保持挂载时,模式切换保留该状态。
|
||||
|
||||
Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure` 钩子。中间 renderer 只透传、不订阅,每次调用拥有独立展开状态。来源回调保留接收对象及稳定引用。外层轮次实际隐藏过程成员时,所在节点重置这些开合状态,不替换组件 key,也不改变钩子引用。展示模式切换保留展开状态。
|
||||
|
||||
-----
|
||||
|
||||
@@ -107,7 +107,11 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
|
||||
<details>
|
||||
<summary>滚动实现——点击展开</summary>
|
||||
|
||||
`useChatViewport` 负责识别轮次的 DOM 读取、限制范围后的写入与原生事件。`useChatReading` 负责跟随策略、读者输入采样和语义位置记忆;`useChatNavigation` 负责轮次跳转与历史前插时的位置保持。`useChatScroll` 协调已提交的输入。显式导航把测得的落点交给阅读策略,因此无需通过命中测试重新寻找已知目标。
|
||||
`useChatViewport` 负责识别轮次的 DOM 读取、限制范围后的写入、原生事件及一个持续保留的分页锚点。加载更早时,Node 与 Group 容器根据已有开合状态标记可选锚点。视口按正文顺序选择第一个非空、未隐藏的标记,不做命中测试或按几何位置搜索,再测量该元素及其滚动容器。先补偿锚点所在的限高组,再把剩余位移交给整段文本的滚动区域。提交和后续内容尺寸变化复用该锚点;消息行重挂载时按同一语义 key 重新定位。补偿限于实际可滚动范围,不增加底部空白。
|
||||
|
||||
`useChatReading` 负责跟随策略、读者输入采样和语义位置记忆;`useChatNavigation` 负责轮次跳转,并向视口请求位置保持。阅读手势释放分页锚点,composer 内的点击、打字和非滚动按键则保留锚点;分页仍在加载时,`scrollend` 会记录读者的新位置。`useChatScroll` 协调已提交的输入。显式导航把测得的落点交给阅读策略,因此无需通过命中测试重新寻找已知目标。
|
||||
|
||||
活跃轮次高亮采用近似定位:`readVisibleTurn` 对正文容器的直属 Node/Group 布局盒二分,落在间隙时保留前方候选。它不做文档命中测试,也不查询 Group 成员或全部 Turn 标记。空 Seat 保留零高度的流内布局盒,使外层位置有序且不增加间距。这种定位不改变语义位置捕获或分页补偿。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -126,7 +130,6 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **二级过程分组尚未实现**——「详细」与「完全展开」目前展示相同的 Chat 内容。「完全展开」仍保留整轮折叠,不会自动打开单个思考或工具正文。
|
||||
|
||||
- **不展示 developer 消息** — 展示能力有意留待后续实现;遇到 `developer/message` 时抛出错误,不渲染回退行。
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-workspace-files": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-approval": "workspace:^",
|
||||
|
||||
@@ -21,7 +21,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type {
|
||||
ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected,
|
||||
ChatNodeInjected, ChatScrollPosition, ChatViewInjected,
|
||||
TurnTailOwnerProps,
|
||||
} from './contract/slots.ts'
|
||||
import type { ChatSnapshot } from './contract/snapshot.ts'
|
||||
@@ -41,12 +41,14 @@ import { LinkOpeningRow, type LinkOpeningRowInjected } from './settings/LinkOpen
|
||||
import { PerformanceUsageRow, type PerformanceUsageRowInjected } from './settings/PerformanceUsageRow.tsx'
|
||||
import { PerformanceUsagePolicy } from './performance-usage.ts'
|
||||
import { useTurnDataValue } from './chat/use-turn-data.ts'
|
||||
import { bindDisclosure } from './chat/use-disclosure.ts'
|
||||
|
||||
const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
|
||||
const CHAT_NODE_INJECT: ChatNodeInjected = {
|
||||
hooks: {
|
||||
turnData: (_standard, data) => function useTurnData(key) {
|
||||
return useTurnDataValue(data, key)
|
||||
turnData: (_standard, { turnData }) => function useTurnData(key) {
|
||||
return useTurnDataValue(turnData, key)
|
||||
},
|
||||
disclosure: (_standard, { disclosureReset }) => bindDisclosure(disclosureReset),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Fragment, memo, useMemo } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { MarkdownFileMentions, MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatNodeOwnerProps, ChatViewSlotProps, UsePresentation } from '../contract/slots.ts'
|
||||
import type { ChatNodeOwnerProps, ChatViewSlotProps, UseDisclosure, UsePresentation } from '../contract/slots.ts'
|
||||
import type { AssistantBlock } from '../contract/snapshot.ts'
|
||||
import { markdownLabels } from '../markdown-labels.ts'
|
||||
import { ReasoningRow } from './ReasoningRow.tsx'
|
||||
@@ -23,6 +23,10 @@ export function localPathMediaUrl(base: string, value: string): string | undefin
|
||||
}
|
||||
|
||||
export interface AssistantMarkdownProps {
|
||||
/** Render only the requested business portion, preserving original block indexes. */
|
||||
groupPart?: string | undefined
|
||||
/** Stable Hook forwarded to each independently expandable reasoning block. */
|
||||
useDisclosure: UseDisclosure
|
||||
blocks: readonly AssistantBlock[]
|
||||
streaming: boolean
|
||||
/** Frozen partial of an aborted turn: rendered with a stopped marker. */
|
||||
@@ -43,7 +47,7 @@ export interface AssistantMarkdownProps {
|
||||
|
||||
/** Reasoning block as the Think variant summary row (figma 39:28304). */
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, renderMessageImages,
|
||||
blocks, streaming, interrupted, renderMessageImages, groupPart, useDisclosure,
|
||||
reasoningHidden = false, usePresentation, revealProcess, mentions, t,
|
||||
}: AssistantMarkdownProps) {
|
||||
// Stable per locale revision (t identity changes on switch): a fresh object
|
||||
@@ -65,6 +69,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i]
|
||||
if (block === undefined) continue
|
||||
if (groupPart === 'reasoning' && block.kind !== 'reasoning') continue
|
||||
if (groupPart === 'response' && block.kind === 'reasoning') continue
|
||||
switch (block.kind) {
|
||||
case 'text':
|
||||
rendered.push(
|
||||
@@ -85,7 +91,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
hidden={reasoningHidden}
|
||||
reveal={revealProcess}
|
||||
>
|
||||
<ReasoningRow text={block.text} running={streaming && i === last} usePresentation={usePresentation} t={t} />
|
||||
<ReasoningRow text={block.text} running={streaming && i === last} usePresentation={usePresentation}
|
||||
useDisclosure={useDisclosure} t={t} />
|
||||
</ProcessReasoning>,
|
||||
)
|
||||
break
|
||||
@@ -131,7 +138,9 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
<div className={css.root} data-streaming={streaming || undefined}>
|
||||
<div className={css.body}>
|
||||
{rendered}
|
||||
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
|
||||
{interrupted && (groupPart === undefined || groupPart === 'response'
|
||||
|| !blocks.some(block => block.kind !== 'reasoning' && block.kind !== 'tool-call'))
|
||||
&& <span className={css.stopped}>{t('message.stopped')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ type AssistantNodeViewProps = ChatNodeViewProps<'assistant-step'> & InjectFace<P
|
||||
|
||||
/** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */
|
||||
export const AssistantNodeView = memo(function AssistantNodeView({
|
||||
node, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, usePresentation, t,
|
||||
node, groupPart, useDisclosure, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, usePresentation, t,
|
||||
}: AssistantNodeViewProps) {
|
||||
const data = node.data
|
||||
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
|
||||
@@ -32,6 +32,8 @@ export const AssistantNodeView = memo(function AssistantNodeView({
|
||||
return (
|
||||
<AssistantMarkdown
|
||||
blocks={data.blocks}
|
||||
groupPart={groupPart}
|
||||
useDisclosure={useDisclosure}
|
||||
streaming={data.status === 'running'}
|
||||
interrupted={data.status === 'interrupted'}
|
||||
renderMessageImages={renderMessageImages}
|
||||
|
||||
@@ -1,3 +1,128 @@
|
||||
.root {
|
||||
display: contents;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: inherit;
|
||||
font-size: var(--dsh-content-font-size, 14px);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: color 100ms ease;
|
||||
}
|
||||
|
||||
.title:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.leading {
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.activityIcon,
|
||||
.chevron {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.activityIcon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.title:is(:hover, :focus-visible) .activityIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.title:is(:hover, :focus-visible) .chevron {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.title[aria-expanded="true"] .activityIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.title[aria-expanded="true"] .chevron {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.title[aria-expanded="true"] {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.body {
|
||||
--dsh-chat-flow-gap: 8px;
|
||||
max-height: min(400px, 50vh);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fadeTop {
|
||||
mask-image: linear-gradient(to bottom, transparent 0, #000 24px, #000 100%);
|
||||
}
|
||||
|
||||
.fadeBottom {
|
||||
mask-image: linear-gradient(to bottom, #000 0, #000 calc(100% - 24px), transparent 100%);
|
||||
}
|
||||
|
||||
.fadeTop.fadeBottom {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
#000 24px,
|
||||
#000 calc(100% - 24px),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.title,
|
||||
.activityIcon,
|
||||
.chevron {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.content > * { flex-shrink: 0; }
|
||||
.content > :not([hidden]):not(:empty) ~ :not([hidden]):not(:empty) {
|
||||
margin-top: var(--dsh-chat-flow-gap, 8px);
|
||||
}
|
||||
.expandedBody {
|
||||
--dsh-chat-flow-gap: 16px;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
@@ -1,30 +1,204 @@
|
||||
/** 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'
|
||||
/** Stable process container; display policy changes visibility, never member parents. */
|
||||
import { memo, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react'
|
||||
import {
|
||||
IconAgentPresetOutlineRegular, IconApiOutlineRegular, IconBrowseOutlineRegular, IconChevronDownOutlineRegular,
|
||||
IconChevronUpOutlineRegular, IconCodeOutlineRegular, IconEditOutlineRegular, IconGlobeOutlineRegular,
|
||||
IconPlanOutlineRegular, IconQuestionOutlineRegular, IconSearchOutlineRegular, IconSparkleRegular,
|
||||
IconThinkOutlineRegular, TextShimmer,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GroupKey, NodeReference } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ProcessActivity } from '../contract/process-groups.ts'
|
||||
import type { ChatStoreState } from '../contract/store.ts'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { storedTurnProcessEntry } from '../stores.ts'
|
||||
import { ChatNodeSeat } from './ChatNodeSeat.tsx'
|
||||
import { chatRenderKey } from './render-entry.ts'
|
||||
import { processTitle } from './step-process.ts'
|
||||
import { useSearchableHidden } from './searchable-hidden.ts'
|
||||
import { useDisclosure } from './use-disclosure.ts'
|
||||
import css from './ChatGroupSeat.module.css'
|
||||
|
||||
type ChatGroupSeatProps = Omit<ComponentProps<typeof ChatNodeSeat>, 'nodeKey' | 'groupPart'> & {
|
||||
type SeatProps = Omit<ComponentProps<typeof ChatNodeSeat>, 'nodeKey' | 'groupPart'>
|
||||
type ChatGroupSeatProps = SeatProps & {
|
||||
readonly groupKey: GroupKey
|
||||
readonly useChatGroup: ChatViewSlotProps['useChatGroup']
|
||||
}
|
||||
interface ProcessScrollEdges { readonly canScrollUp: boolean; readonly canScrollDown: boolean }
|
||||
const PROCESS_SCROLL_AT_REST: ProcessScrollEdges = { canScrollUp: false, canScrollDown: false }
|
||||
const PROCESS_TITLE_MINIMUM_MS = 150
|
||||
|
||||
/** Render the group's members without moving them between React parents on mode changes. */
|
||||
type ProcessTitleActivity = ProcessActivity | 'thinking'
|
||||
|
||||
interface LiveProcessTitle {
|
||||
readonly activity: ProcessTitleActivity
|
||||
readonly detail: string
|
||||
}
|
||||
|
||||
const PROCESS_ICONS: Record<ProcessTitleActivity, ReactNode> = {
|
||||
thinking: <IconThinkOutlineRegular />,
|
||||
read: <IconBrowseOutlineRegular size={14} />,
|
||||
search: <IconSearchOutlineRegular size={14} />,
|
||||
edit: <IconEditOutlineRegular size={14} />,
|
||||
commands: <IconApiOutlineRegular />,
|
||||
code: <IconCodeOutlineRegular size={14} />,
|
||||
webSearch: <IconGlobeOutlineRegular />,
|
||||
webFetch: <IconBrowseOutlineRegular size={14} />,
|
||||
subagents: <IconAgentPresetOutlineRegular size={14} />,
|
||||
plan: <IconPlanOutlineRegular />,
|
||||
questions: <IconQuestionOutlineRegular />,
|
||||
tools: <IconSparkleRegular size={14} />,
|
||||
}
|
||||
|
||||
function processScrollEdges(element: HTMLElement): ProcessScrollEdges {
|
||||
return {
|
||||
canScrollUp: element.scrollTop > 1,
|
||||
canScrollDown: element.scrollTop < element.scrollHeight - element.clientHeight - 1,
|
||||
}
|
||||
}
|
||||
|
||||
function sameProcessScrollEdges(left: ProcessScrollEdges, right: ProcessScrollEdges): boolean {
|
||||
return left.canScrollUp === right.canScrollUp && left.canScrollDown === right.canScrollDown
|
||||
}
|
||||
|
||||
function sameLiveProcessTitle(left: LiveProcessTitle, right: LiveProcessTitle): boolean {
|
||||
return left.activity === right.activity && left.detail === right.detail
|
||||
}
|
||||
|
||||
function useStableLiveProcessTitle(desired: LiveProcessTitle, active: boolean): LiveProcessTitle {
|
||||
const [displayed, setDisplayed] = useState(desired)
|
||||
const displayedRef = useRef(displayed)
|
||||
const desiredRef = useRef(desired)
|
||||
const displayedAtRef = useRef(Date.now())
|
||||
useEffect(() => {
|
||||
desiredRef.current = desired
|
||||
if (!active || sameLiveProcessTitle(displayedRef.current, desired)) return
|
||||
const remaining = PROCESS_TITLE_MINIMUM_MS - (Date.now() - displayedAtRef.current)
|
||||
const commit = (): void => {
|
||||
const next = desiredRef.current
|
||||
displayedRef.current = next
|
||||
displayedAtRef.current = Date.now()
|
||||
setDisplayed(next)
|
||||
}
|
||||
if (remaining <= 0) {
|
||||
commit()
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(commit, remaining)
|
||||
return () => { clearTimeout(timer) }
|
||||
}, [active, desired.activity, desired.detail])
|
||||
return active ? displayed : desired
|
||||
}
|
||||
|
||||
|
||||
const GroupMembers = memo(function GroupMembers({ members, ...props }: SeatProps & {
|
||||
readonly members: readonly NodeReference[]
|
||||
}) {
|
||||
return members.map(member => <ChatNodeSeat {...props} key={chatRenderKey(member)} nodeKey={member.key}
|
||||
{...member.groupPart === undefined ? {} : { groupPart: member.groupPart }} />)
|
||||
})
|
||||
|
||||
const ProcessGroupHeader = memo(function ProcessGroupHeader({ groupKey, useChatGroup, usePresentation, t, open, bodyId, toggle }: {
|
||||
readonly groupKey: GroupKey
|
||||
readonly useChatGroup: ChatViewSlotProps['useChatGroup']
|
||||
readonly usePresentation: ChatViewSlotProps['usePresentation']
|
||||
readonly t: ChatViewSlotProps['t']
|
||||
readonly open: boolean
|
||||
readonly bodyId: string
|
||||
readonly toggle: () => void
|
||||
}) {
|
||||
const data = useChatGroup(groupKey, group => group?.data)
|
||||
const detailed = usePresentation(policy => data?.closed === false && policy.liveProcessDetail)
|
||||
const live = useStableLiveProcessTitle({
|
||||
activity: data?.summary.running ?? 'thinking',
|
||||
detail: data?.summary.runningDetail ?? '',
|
||||
}, data !== undefined && !data.closed)
|
||||
if (data === undefined) return null
|
||||
const label = data.closed ? processTitle(data.summary, t) : t(`message.stepProcess.${live.activity}`)
|
||||
const detail = detailed && !data.closed ? live.detail : ''
|
||||
const title = detail === '' ? label : `${label}${t('message.turnProcess.separator')}${detail}`
|
||||
const activity = data.closed ? data.summary.counts[0]?.kind ?? 'thinking' : live.activity
|
||||
return (
|
||||
<button type="button" className={css.title} aria-expanded={open} aria-controls={bodyId}
|
||||
data-process-activity={activity} onClick={(event) => { event.currentTarget.focus(); toggle() }}>
|
||||
<span className={css.leading} aria-hidden="true">
|
||||
<span className={css.activityIcon} data-step-process-icon>{PROCESS_ICONS[activity]}</span>
|
||||
<span className={css.chevron} data-step-process-chevron>
|
||||
{open ? <IconChevronUpOutlineRegular /> : <IconChevronDownOutlineRegular />}
|
||||
</span>
|
||||
</span>
|
||||
<TextShimmer active={!data.closed} className={css.label}>{title}</TextShimmer>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
/** Render a process group with local disclosure and the existing outer-Turn visibility. */
|
||||
export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGroup, ...props }: ChatGroupSeatProps) {
|
||||
const members = useChatGroup(groupKey, group => group?.members)
|
||||
const turn = useChatGroup(groupKey, group => group?.data.turn)
|
||||
const foldCompleted = props.usePresentation(policy => policy.foldCompletedTurns)
|
||||
const { expanded: open, setExpanded: setOpen, toggle } = useDisclosure()
|
||||
const firstKey = members?.[0]?.key ?? ''
|
||||
const presentation = props.useChatNodeProcess(firstKey)
|
||||
const turnLocation = props.useChatNode(firstKey, (node) => {
|
||||
const location = node?.location
|
||||
return location?.kind === 'turn' || location?.kind === 'step' ? location.turn : undefined
|
||||
})
|
||||
const grouped = props.usePresentation(policy => turnLocation?.status !== 'open' || policy.stepGrouping !== 'none')
|
||||
const reason = turnLocation?.end?.data.reason.kind
|
||||
const alwaysOpen = presentation?.turnClosed === false || presentation?.hasInterleavedInput === true
|
||||
|| reason === 'aborted' || reason === 'error'
|
||||
const spec = presentation?.spec
|
||||
const selectStored = useCallback((state: Readonly<ChatStoreState>) => turn === undefined
|
||||
? undefined : storedTurnProcessEntry(state, turn), [turn])
|
||||
const stored = props.useStore(selectStored)
|
||||
const outerHidden = foldCompleted && presentation?.turnClosed === true && spec !== undefined
|
||||
&& !alwaysOpen && stored?.answerStep !== (spec.answerStep ?? 0)
|
||||
const revealOuter = useCallback(() => {
|
||||
if (spec !== undefined && !alwaysOpen) props.actions.setTurnProcessOpen(spec.turn, spec.answerStep ?? 0, true)
|
||||
}, [props.actions, spec, alwaysOpen])
|
||||
const rootRef = useSearchableHidden(outerHidden, revealOuter)
|
||||
useEffect(() => {
|
||||
if (outerHidden && rootRef.current?.hasAttribute('hidden')) setOpen(false)
|
||||
}, [outerHidden, rootRef, setOpen])
|
||||
const reveal = useCallback(() => { setOpen(true) }, [setOpen])
|
||||
const bodyRef = useSearchableHidden(grouped && !open, reveal)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const bodyId = useId()
|
||||
const [edges, setEdges] = useState<ProcessScrollEdges>(PROCESS_SCROLL_AT_REST)
|
||||
const sync = useCallback(() => {
|
||||
const body = bodyRef.current
|
||||
const next = body === null || body.closest('[hidden], [data-group-expanded-mode]') !== null
|
||||
? PROCESS_SCROLL_AT_REST : processScrollEdges(body)
|
||||
setEdges(previous => sameProcessScrollEdges(previous, next) ? previous : next)
|
||||
}, [bodyRef])
|
||||
// The content box reports growth even when the scroll body remains height-capped.
|
||||
useLayoutEffect(() => {
|
||||
const body = bodyRef.current
|
||||
if (body === null || !open || typeof ResizeObserver === 'undefined') return
|
||||
const observer = new ResizeObserver(sync)
|
||||
observer.observe(body)
|
||||
if (contentRef.current !== null) observer.observe(contentRef.current)
|
||||
return () => { observer.disconnect() }
|
||||
}, [bodyRef, open, sync])
|
||||
if (members === undefined) return null
|
||||
const classes = [css.body, !grouped ? css.expandedBody : '',
|
||||
grouped && edges.canScrollUp ? css.fadeTop : '', grouped && edges.canScrollDown ? css.fadeBottom : '']
|
||||
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 ref={rootRef} className={css.root} data-chat-group-key={groupKey}
|
||||
data-chat-flow-key={groupKey} data-chat-anchor-key={`group:${groupKey}`} data-chat-turn={turn}
|
||||
data-chat-paging-anchor={grouped && !open || undefined}
|
||||
data-step-process data-group-expanded-mode={!grouped || undefined}>
|
||||
<div hidden={!grouped}>
|
||||
<ProcessGroupHeader groupKey={groupKey} useChatGroup={useChatGroup}
|
||||
usePresentation={props.usePresentation} t={props.t} open={open} bodyId={bodyId} toggle={toggle} />
|
||||
</div>
|
||||
<div ref={bodyRef} id={bodyId} className={classes.join(' ')} data-step-process-body
|
||||
data-scroll-up={edges.canScrollUp || undefined} data-scroll-down={edges.canScrollDown || undefined}
|
||||
onScroll={sync}>
|
||||
<div ref={contentRef} className={css.content} data-step-process-content data-chat-flow="">
|
||||
<GroupMembers {...props} members={members} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ConversationLocationDataStore, ConversationTurnDataMap } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ChatNodeOwnerProps, ChatViewSlotProps, UsePresentation } from '../contract/slots.ts'
|
||||
import type { ChatNodeHookContext, 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 { TURN_PROCESS_INDEPENDENT_KINDS, turnProcessAlwaysOpen } from '../contract/turn-process.ts'
|
||||
import { storedTurnProcessEntry } from '../stores.ts'
|
||||
import { useSearchableHidden } from './searchable-hidden.ts'
|
||||
import css from './ChatView.module.css'
|
||||
@@ -55,47 +56,50 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
|
||||
? undefined
|
||||
: storedTurnProcessEntry(state, processSpec.turn))
|
||||
const processEntry = processSpec !== undefined
|
||||
&& processSpec.answerStep !== null
|
||||
&& storedEntry?.answerStep === processSpec.answerStep
|
||||
&& storedEntry?.answerStep === (processSpec.answerStep ?? 0)
|
||||
? storedEntry
|
||||
: undefined
|
||||
const processOpen = processEntry !== undefined
|
||||
const liveProcess = processPresentation !== undefined && !processPresentation.turnClosed
|
||||
const interleavedInput = processPresentation?.hasInterleavedInput === true
|
||||
const alwaysOpen = liveProcess || interleavedInput || turnProcessAlwaysOpen(routedNode)
|
||||
const processOpen = alwaysOpen || processEntry !== undefined
|
||||
const setOpen = useCallback((open: boolean) => {
|
||||
if (processSpec !== undefined && processSpec.answerStep !== null) {
|
||||
actions.setTurnProcessOpen(processSpec.turn, processSpec.answerStep, open)
|
||||
if (processSpec !== undefined && !alwaysOpen) {
|
||||
actions.setTurnProcessOpen(processSpec.turn, processSpec.answerStep ?? 0, open)
|
||||
}
|
||||
}, [actions, processSpec])
|
||||
}, [actions, processSpec, alwaysOpen])
|
||||
const foldCompleted = usePresentation(policy => policy.foldCompletedTurns)
|
||||
// Folding is decided per Turn: the Turn is closed and its start is loaded.
|
||||
// A loaded end makes a partial historical Turn eligible without its start.
|
||||
const processWindowReady = processSpec !== undefined
|
||||
&& processPresentation !== undefined
|
||||
&& foldCompleted
|
||||
&& processSpec.answerAnchorSeq !== null
|
||||
&& processPresentation.turn === processSpec.turn
|
||||
&& processPresentation.turnClosed
|
||||
&& processPresentation.turnStarted
|
||||
&& (processPresentation.turnStarted || processPresentation.turnClosed)
|
||||
const processMember = routedNode !== undefined
|
||||
&& processWindowReady
|
||||
&& !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind)
|
||||
&& routedNode.anchorSeq >= processSpec.processStartSeq
|
||||
&& routedNode.anchorSeq < processSpec.answerAnchorSeq
|
||||
&& (liveProcess || processSpec.answerAnchorSeq === null || routedNode.anchorSeq < processSpec.answerAnchorSeq
|
||||
|| (groupPart === 'reasoning' && routedNode.kind === 'assistant-step' && routedNode.data.step === processSpec.answerStep))
|
||||
const processAnswer = routedNode !== undefined
|
||||
&& processWindowReady
|
||||
&& !liveProcess
|
||||
&& groupPart !== 'reasoning'
|
||||
&& routedNode.kind === 'assistant-step'
|
||||
&& routedNode.data.step === processSpec.answerStep
|
||||
const ownsDisclosure = routedNode?.kind === 'turn-process' || processAnswer
|
||||
const foldable = processWindowReady
|
||||
&& (processMember || (ownsDisclosure
|
||||
&& (processPresentation.hasExternalProcess || processSpec.inlineReasoning)))
|
||||
&& (liveProcess || processMember || ownsDisclosure)
|
||||
const turnProcess = useMemo(() => processSpec === undefined
|
||||
? undefined
|
||||
: {
|
||||
spec: processSpec,
|
||||
foldable,
|
||||
hasContent: !interleavedInput && (processPresentation?.hasExternalProcess === true || processSpec.inlineReasoning),
|
||||
open: processOpen,
|
||||
setOpen,
|
||||
}, [
|
||||
foldable, processOpen, processSpec, setOpen,
|
||||
foldable, interleavedInput, processOpen, processSpec, processPresentation?.hasExternalProcess, setOpen,
|
||||
])
|
||||
const controllerInactive = routedNode?.kind === 'turn-process'
|
||||
&& !foldable
|
||||
@@ -108,6 +112,14 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
|
||||
if (processMember) setOpen(true)
|
||||
}, [processMember, setOpen])
|
||||
const wrapperRef = useSearchableHidden(processHidden, revealProcess)
|
||||
const [disclosureReset] = useState(() => createSnapshotStore(0))
|
||||
const turnData = turnDataOf(routedNode)
|
||||
const hookContext = useMemo<ChatNodeHookContext>(() => ({ turnData, disclosureReset }), [turnData, disclosureReset])
|
||||
useEffect(() => {
|
||||
if (processMember && processHidden && wrapperRef.current?.hasAttribute('hidden')) {
|
||||
disclosureReset.set(disclosureReset.getSnapshot() + 1)
|
||||
}
|
||||
}, [processMember, processHidden, wrapperRef, disclosureReset])
|
||||
const owner = useMemo<ChatNodeOwnerProps | null>(() => node === undefined
|
||||
? null
|
||||
: {
|
||||
@@ -126,18 +138,18 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
|
||||
loadImage, renderMessageImages, fileMentions, turnProcess,
|
||||
])
|
||||
if (routedNode === undefined || owner === null) return null
|
||||
const turnData = turnDataOf(routedNode)
|
||||
// Runtime dispatch owns the correlation: every Node's discriminant is the
|
||||
// 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])
|
||||
const flowKey = groupPart === undefined || groupPart === 'response' ? routedNode.key : JSON.stringify([routedNode.key, groupPart])
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={css.flowItem}
|
||||
data-chat-anchor-key={flowKey}
|
||||
data-chat-flow-key={flowKey}
|
||||
data-chat-paging-anchor={routedNode.kind !== 'turn-process' || undefined}
|
||||
data-chat-node-key={routedNode.key}
|
||||
data-chat-group-part={groupPart}
|
||||
data-chat-flow-kind={routedNode.kind}
|
||||
@@ -148,7 +160,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
|
||||
>
|
||||
{renderSlot('conversation.chat.node', routedOwner, {
|
||||
entryKey: routedNode.kind,
|
||||
hookContext: turnData,
|
||||
hookContext,
|
||||
fallback: (
|
||||
<JsonBlock
|
||||
label={t('message.unknownSurface', { type: routedNode.kind })}
|
||||
|
||||
@@ -68,11 +68,10 @@
|
||||
--dsh-chat-flow-gap: 8px;
|
||||
}
|
||||
|
||||
/* A keyed renderer may intentionally decline its row after dispatch (the
|
||||
completed-turn tail does this when it owns neither actions nor extensions).
|
||||
An empty flex item must not consume the column gap. */
|
||||
/* Empty Seats retain ordered layout positions for Turn lookup without
|
||||
contributing height or the sibling gap. */
|
||||
.flowItem:empty {
|
||||
display: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.callRow {
|
||||
@@ -82,65 +81,6 @@
|
||||
/* Selection still sets data-selected for details linkage; no outline —
|
||||
tool rows match Think chrome (no selected ring). */
|
||||
|
||||
/* Turn activity keeps the former loader's one-line footprint. A pale
|
||||
brand-blue band sweeps from left to right; reduced-motion keeps it static. */
|
||||
.turnStatus {
|
||||
align-self: flex-start;
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: calc(26px + var(--dsh-content-font-delta, 0px));
|
||||
font: var(--dsw-font-s-strong-14);
|
||||
/* The status line reads at the body size: the fixed 14 token supplies the
|
||||
family/weight, the axis supplies the size. At default that is 14px text
|
||||
on a 22px line inside a 26px row; both box heights shift by the same
|
||||
delta, preserving the 8px (22 − 14) leading and the 4px (26 − 22) row
|
||||
padding at every size. */
|
||||
font-size: var(--dsh-content-font-size, 14px);
|
||||
line-height: calc(22px + var(--dsh-content-font-delta, 0px));
|
||||
white-space: nowrap;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--dsw-static-deepseek-500) 0%,
|
||||
var(--dsw-static-deepseek-500) 40%,
|
||||
var(--dsw-static-deepseek-200) 50%,
|
||||
var(--dsw-static-deepseek-500) 60%,
|
||||
var(--dsw-static-deepseek-500) 100%
|
||||
);
|
||||
background-position: 100% 0;
|
||||
background-size: 250% 100%;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: dsh-turn-status-shimmer 1.8s linear infinite;
|
||||
}
|
||||
|
||||
.turnStatusClock {
|
||||
margin-left: 8px;
|
||||
font: var(--dsw-font-xs-13);
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
|
||||
font-weight: 400;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
-webkit-text-fill-color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
@keyframes dsh-turn-status-shimmer {
|
||||
to {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.turnStatus {
|
||||
background-position: 0 0;
|
||||
background-size: 100% 100%;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// An enclosing `[data-conversation-scroll]` owns scrolling when present;
|
||||
// otherwise this view owns it. Each row subscribes to one stable node key.
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from 'react'
|
||||
import { memo, useCallback, useMemo, useRef, useState, type ComponentProps } from 'react'
|
||||
import type {
|
||||
ConversationTimelineSnapshot, NodeKey, RenderEntry, RenderMessageImages,
|
||||
NodeKey, RenderEntry, RenderMessageImages,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
|
||||
import {
|
||||
@@ -19,7 +19,6 @@ 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'
|
||||
import { formatRunDuration } from './message-chrome.ts'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
/** Host/OS refusal text for the file-open dialog; empty throws keep a locale fallback. */
|
||||
@@ -39,7 +38,7 @@ function observedRpcIds(
|
||||
order: readonly string[],
|
||||
nodes: ChatSnapshot['nodes'],
|
||||
inbox: InboxState | undefined,
|
||||
): ReadonlySet<string> {
|
||||
): { readonly durable: ReadonlySet<string>; readonly pending: ReadonlySet<string> } {
|
||||
const observed = new Set<string>()
|
||||
for (const key of order) {
|
||||
const node = nodes.get(key)
|
||||
@@ -49,54 +48,11 @@ function observedRpcIds(
|
||||
| undefined
|
||||
if (source?.kind === 'user' && typeof source.rpcId === 'string') observed.add(source.rpcId)
|
||||
}
|
||||
const pending = new Set<string>()
|
||||
for (const { source } of [...inbox?.['next-turn'] ?? [], ...inbox?.['next-step'] ?? []]) {
|
||||
if (source.kind === 'user' && 'rpcId' in source) observed.add(source.rpcId)
|
||||
if (source.kind === 'user' && 'rpcId' in source) pending.add(source.rpcId)
|
||||
}
|
||||
return observed
|
||||
}
|
||||
|
||||
function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null {
|
||||
let latest: number | null = null
|
||||
for (const turn of timeline.turns.values()) {
|
||||
if (turn.status === 'open') latest = turn.start?.time ?? null
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
/** Turn-level model activity label retained across first-token, tool, and streaming phases. */
|
||||
function TurnStatus({ startTime, t }: {
|
||||
/** The running turn's logged `turn/start` time; null falls back to mount
|
||||
* time when that boundary is outside the window. */
|
||||
startTime: number | null
|
||||
/** The owning view's locale seat. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const [mountedAt] = useState(() => Date.now())
|
||||
// Anchored to turn/start so a mid-turn reload keeps the real
|
||||
// elapsed time and the final footer's Ran-for label matches this clock.
|
||||
const anchor = startTime ?? mountedAt
|
||||
const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - anchor))
|
||||
useEffect(() => {
|
||||
const tick = (): void => {
|
||||
setElapsedMs(Math.max(0, Date.now() - anchor))
|
||||
}
|
||||
tick()
|
||||
const id = setInterval(tick, 1000)
|
||||
return () => { clearInterval(id) }
|
||||
}, [anchor])
|
||||
// Short turns keep the plain label; the clock only appears once the turn
|
||||
// has clearly been running for a while.
|
||||
const showClock = elapsedMs >= 15_000
|
||||
return (
|
||||
<div className={css.turnStatus} role="status" aria-live="polite">
|
||||
{t('chat.deepDiving')}
|
||||
{showClock && (
|
||||
<span className={css.turnStatusClock} aria-hidden>
|
||||
{formatRunDuration(elapsedMs, t)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
return { durable: observed, pending }
|
||||
}
|
||||
|
||||
type ChatNodeListProps = Omit<ComponentProps<typeof ChatNodeSeat>, 'nodeKey' | 'groupPart'> & {
|
||||
@@ -143,7 +99,6 @@ export function ChatView({
|
||||
() => mergeTurnRailItems(turnNavigationItems, turnOutline),
|
||||
[turnNavigationItems, turnOutline],
|
||||
)
|
||||
const timeline = useChat(s => s.timeline)
|
||||
const inbox = useProjection('inbox') as unknown as InboxState | undefined
|
||||
// Workspace root off the session list row: path summaries display relative to it.
|
||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||
@@ -187,7 +142,7 @@ export function ChatView({
|
||||
setFileOpenBusy(false)
|
||||
}, [])
|
||||
|
||||
const pendingSteering = useMemo(
|
||||
const inboxSteering = useMemo(
|
||||
() => inbox?.['next-step'].filter(message => message.source.kind === 'user') ?? [],
|
||||
[inbox],
|
||||
)
|
||||
@@ -199,23 +154,38 @@ export function ChatView({
|
||||
if (pendingSubmissions.length === 0) return pendingSubmissions
|
||||
const observed = observedRpcIds(order, nodeStore, inbox)
|
||||
return pendingSubmissions.filter(submission => (
|
||||
submission.placement !== 'queued' && !observed.has(submission.requestId)
|
||||
submission.placement !== 'queued' && !observed.durable.has(submission.requestId)
|
||||
&& (submission.placement === 'steering' || !observed.pending.has(submission.requestId))
|
||||
))
|
||||
}, [pendingSubmissions, order, nodeStore, inbox])
|
||||
const pendingInputs = useMemo(() => {
|
||||
const local = new Map(visibleSubmissions.map(submission => [submission.requestId, submission]))
|
||||
const pending = inboxSteering.map((item) => {
|
||||
const source = item.source
|
||||
if (source.kind !== 'user' || !('rpcId' in source)) return item
|
||||
const submission = local.get(source.rpcId)
|
||||
if (submission === undefined) return item
|
||||
local.delete(source.rpcId)
|
||||
return submission
|
||||
})
|
||||
return [...pending, ...local.values()]
|
||||
}, [inboxSteering, visibleSubmissions])
|
||||
const renderMessageImages = useCallback<RenderMessageImages>(
|
||||
owner => renderSlot('conversation.message.images', { ...owner, loadImage }),
|
||||
[loadImage, renderSlot],
|
||||
)
|
||||
const runningTurnStart = useMemo(() => runningTurnStartTime(timeline), [timeline])
|
||||
|
||||
const firstKey = order[0]
|
||||
const firstSeq = firstKey === undefined ? null : nodeStore.get(firstKey)?.anchorSeq ?? null
|
||||
const lastKey = order.at(-1) ?? null
|
||||
const latestSteering = pendingInputs.findLast(item => 'source' in item)
|
||||
const steeringId = latestSteering?.source.kind === 'user' && 'rpcId' in latestSteering.source
|
||||
? latestSteering.source.rpcId : latestSteering?.id ?? null
|
||||
const scroll = useChatScroll({
|
||||
ready: openState === 'open',
|
||||
order, firstSeq, lastKey, running, loadingOlder, hasMore, chatScroll, loadOlder, loadThrough,
|
||||
lastIsUser: lastKey !== null && nodeStore.get(lastKey)?.kind === 'user',
|
||||
steeringId: pendingSteering.at(-1)?.id ?? null,
|
||||
steeringId,
|
||||
submissionId: visibleSubmissions.at(-1)?.requestId ?? null,
|
||||
loadedTurns: turnNavigationItems,
|
||||
})
|
||||
@@ -271,21 +241,17 @@ export function ChatView({
|
||||
{/* No pending placeholders: questions (ui-user-questions) and approvals
|
||||
(ApprovalPanel) both take over the composer, so a flow card would
|
||||
double-render the same wait. */}
|
||||
{/* Turn-level loading signal: rides the whole running turn (first-token
|
||||
wait, tool execution, streaming) so it never flickers per step. */}
|
||||
{running && <TurnStatus startTime={runningTurnStart} t={t} />}
|
||||
{pendingSteering.map(item => (
|
||||
<PendingSteeringBubble
|
||||
key={item.id}
|
||||
content={item.content}
|
||||
{pendingInputs.map(item => 'requestId' in item ? (
|
||||
<PendingSubmissionBubble
|
||||
key={item.requestId}
|
||||
submission={item}
|
||||
renderMessageImages={renderMessageImages}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
{visibleSubmissions.map(submission => (
|
||||
<PendingSubmissionBubble
|
||||
key={submission.requestId}
|
||||
submission={submission}
|
||||
) : (
|
||||
<PendingSteeringBubble
|
||||
key={item.id}
|
||||
content={item.content}
|
||||
renderMessageImages={renderMessageImages}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Assistant reasoning disclosure, independent of Tool-call presentation. */
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { DisclosureRow, IconThinkOutlineRegular, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps, UsePresentation } from '../contract/slots.ts'
|
||||
import type { ChatViewSlotProps, UseDisclosure, UsePresentation } from '../contract/slots.ts'
|
||||
import { markdownLabels } from '../markdown-labels.ts'
|
||||
import a11yCss from './accessibility.module.css'
|
||||
import css from './ReasoningRow.module.css'
|
||||
@@ -40,18 +40,19 @@ function latestCompletedParagraphFirstLine(text: string): string {
|
||||
* @param props.text - complete or streaming reasoning text.
|
||||
* @param props.running - whether this block is the streaming tail.
|
||||
* @param props.usePresentation - live display-policy selector for this reasoning row.
|
||||
* @param props.useDisclosure - independent open state with enclosing-Turn resets.
|
||||
* @param props.t - conversation locale seat for status and Markdown actions.
|
||||
* @returns the reasoning disclosure.
|
||||
*/
|
||||
export const ReasoningRow = memo(function ReasoningRow({ text, running, usePresentation, t }: {
|
||||
export const ReasoningRow = memo(function ReasoningRow({ text, running, usePresentation, useDisclosure, t }: {
|
||||
text: string
|
||||
running: boolean
|
||||
useDisclosure: UseDisclosure
|
||||
usePresentation: UsePresentation
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const { expanded, toggle } = useDisclosure()
|
||||
const labels = useMemo(() => markdownLabels(t), [t])
|
||||
const toggle = useCallback(() => { setExpanded(value => !value) }, [])
|
||||
const summaryText = running ? latestCompletedParagraphFirstLine(text) : firstLine(text)
|
||||
const summary = useMemo(() => summaryText.replaceAll('**', ''), [summaryText])
|
||||
const preview = usePresentation(policy => !expanded && summary !== ''
|
||||
|
||||
@@ -4,14 +4,23 @@
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 33px;
|
||||
height: calc(33px + var(--dsh-content-font-delta, 0px));
|
||||
padding: 0 0 8px;
|
||||
border: none;
|
||||
border-bottom: 0.5px solid var(--dsw-alias-border-l2);
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: color 100ms ease;
|
||||
}
|
||||
|
||||
.root:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.root:not(:disabled):hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.root:not([data-open]) {
|
||||
@@ -20,28 +29,28 @@
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
transform: rotate(-90deg);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-left: 4px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
transition: transform 100ms ease;
|
||||
}
|
||||
|
||||
.root[data-open] .chevron {
|
||||
transform: rotate(0deg);
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.root,
|
||||
.chevron {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { memo } from 'react'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { IconChevronDownOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatNodeViewProps } from '../contract/slots.ts'
|
||||
import { turnProcessAlwaysOpen } from '../contract/turn-process.ts'
|
||||
import { formatLiveRunDuration, formatRunDuration, LIVE_RUN_CLOCK_INTERVAL_MS } from './message-chrome.ts'
|
||||
import a11yCss from './accessibility.module.css'
|
||||
import css from './TurnProcessNodeView.module.css'
|
||||
|
||||
/** Turn-level process disclosure controller. */
|
||||
@@ -8,53 +11,58 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({
|
||||
node, turnProcess, t,
|
||||
}: ChatNodeViewProps<'turn-process'>) {
|
||||
if (turnProcess === undefined) throw new Error('turn-process node requires Turn process owner state')
|
||||
if (!turnProcess.foldable) return null
|
||||
const open = turnProcess.open
|
||||
const labels: string[] = []
|
||||
if (node.data.toolCallCount > 0) {
|
||||
labels.push(t(
|
||||
node.data.toolCallCount === 1
|
||||
? 'message.turnProcess.toolCalls.one'
|
||||
: 'message.turnProcess.toolCalls.other',
|
||||
{ count: node.data.toolCallCount },
|
||||
))
|
||||
}
|
||||
if (node.data.messageCount > 0) {
|
||||
labels.push(t(
|
||||
node.data.messageCount === 1
|
||||
? 'message.turnProcess.messages.one'
|
||||
: 'message.turnProcess.messages.other',
|
||||
{ count: node.data.messageCount },
|
||||
))
|
||||
}
|
||||
if (node.data.subagentCount > 0) {
|
||||
labels.push(t(
|
||||
node.data.subagentCount === 1
|
||||
? 'message.turnProcess.subagents.one'
|
||||
: 'message.turnProcess.subagents.other',
|
||||
{ count: node.data.subagentCount },
|
||||
))
|
||||
}
|
||||
const label = labels.length === 0
|
||||
? t('message.turnProcess.thoughtForAWhile')
|
||||
: labels.join(t('message.turnProcess.separator'))
|
||||
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
|
||||
? node.location.turn
|
||||
: undefined
|
||||
const [now, setNow] = useState(Date.now)
|
||||
const ticking = turnProcess.foldable && turn?.status === 'open'
|
||||
useEffect(() => {
|
||||
if (!ticking) return
|
||||
setNow(Date.now())
|
||||
const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS)
|
||||
return () => { clearInterval(timer) }
|
||||
}, [ticking])
|
||||
if (!turnProcess.foldable) return null
|
||||
const canCollapse = turnProcess.hasContent && !turnProcessAlwaysOpen(node)
|
||||
const running = turn?.status === 'open'
|
||||
const reason = turn?.end?.data.reason.kind
|
||||
const elapsedMs = turn?.start === undefined ? undefined
|
||||
: Math.max(1000, (turn.end?.time ?? now) - turn.start.time)
|
||||
const duration = elapsedMs === undefined ? undefined
|
||||
: running ? formatLiveRunDuration(elapsedMs, t) : formatRunDuration(elapsedMs, t)
|
||||
// Other end reasons retain elapsed time; only cancellation and failure replace it.
|
||||
const label = running
|
||||
? duration === undefined ? t('chat.deepDiving') : t('message.turnProcess.deepDivingFor', { duration })
|
||||
: reason === 'aborted' ? t('message.stopped')
|
||||
: reason === 'error' ? t('message.turnProcess.failed')
|
||||
: duration === undefined ? t('message.turnProcess.worked')
|
||||
: t('message.turnProcess.took', { duration })
|
||||
const announcement = running ? t('chat.deepDiving')
|
||||
: reason === 'aborted' ? t('message.stopped')
|
||||
: reason === 'error' ? t('message.turnProcess.failed')
|
||||
: t('message.turnProcess.worked')
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.root}
|
||||
data-open={open || undefined}
|
||||
data-turn-process={node.data.turn}
|
||||
data-turn-process-messages={node.data.messageCount}
|
||||
data-turn-process-tool-calls={node.data.toolCallCount}
|
||||
data-turn-process-subagents={node.data.subagentCount}
|
||||
aria-expanded={open}
|
||||
onClick={(event) => {
|
||||
event.currentTarget.focus()
|
||||
turnProcess.setOpen(!open)
|
||||
}}
|
||||
>
|
||||
<span className={css.label}>{label}</span>
|
||||
<IconChevronDownOutlineRegular className={css.chevron} />
|
||||
</button>
|
||||
<>
|
||||
<span className={a11yCss.visuallyHidden} role="status" aria-live="polite" aria-atomic="true">{announcement}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.root}
|
||||
data-open={open || undefined}
|
||||
data-turn-process={node.data.turn}
|
||||
data-turn-process-messages={node.data.messageCount}
|
||||
data-turn-process-tool-calls={node.data.toolCallCount}
|
||||
data-turn-process-subagents={node.data.subagentCount}
|
||||
disabled={!canCollapse}
|
||||
aria-expanded={turnProcess.hasContent ? open : undefined}
|
||||
onClick={(event) => {
|
||||
event.currentTarget.focus()
|
||||
turnProcess.setOpen(!open)
|
||||
}}
|
||||
>
|
||||
<span className={css.label}>{label}</span>
|
||||
{canCollapse && <IconChevronDownOutlineRegular className={css.chevron} />}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -4,12 +4,26 @@ import type { ChatNodeViewProps, PerformanceUsageInjected, TurnTailOwnerProps }
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { TurnUsagePanel } from './TurnUsagePanel.tsx'
|
||||
import { assistantText } from './turn-assistant.ts'
|
||||
import { hasAssistantReplyContent } from '../contract/assistant-content.ts'
|
||||
import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
import type { ChatSnapshot } from '../contract/snapshot.ts'
|
||||
import css from './TurnTailNodeView.module.css'
|
||||
|
||||
type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'>
|
||||
& PropsRenderSlots<'conversation.chat.turnTail' | 'conversation.chat.assistant-actions'>
|
||||
& InjectFace<PerformanceUsageInjected>
|
||||
|
||||
function lastContent(snapshot: ChatSnapshot, turn: number, skipWarning: boolean): ChatNode | undefined {
|
||||
const keys = snapshot.locations.getTurn(turn)
|
||||
for (let index = keys.length - 1; index >= 0; index--) {
|
||||
const node = snapshot.nodes.get(keys[index] as string) as ChatNode | undefined
|
||||
if (node === undefined || node.kind === 'turn-tail' || node.kind === 'turn-process'
|
||||
|| (skipWarning && node.kind === 'turn-max-tokens')) continue
|
||||
return node
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */
|
||||
export const TurnTailNodeView = memo(function TurnTailNodeView({
|
||||
node, openFile, forkAt, renderSlot, t, useChat, usePerformanceUsage,
|
||||
@@ -17,8 +31,14 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
|
||||
const detailed = usePerformanceUsage(mode => mode) === 'detailed'
|
||||
const data = node.data
|
||||
const hasLaterChatNode = useChat(snapshot =>
|
||||
snapshot.locations.getTurn(data.turn).at(-1) !== node.key)
|
||||
const isLatestTurn = useChat(snapshot => snapshot.timeline.turnOrder.at(-1) === data.turn)
|
||||
(lastContent(snapshot, data.turn, true)?.anchorSeq ?? -1) > (data.closing?.finalNode.seq ?? data.seq))
|
||||
const endsWithResponse = useChat((snapshot) => {
|
||||
if (snapshot.timeline.turnOrder.at(-1) !== data.turn) return false
|
||||
const last = lastContent(snapshot, data.turn, false)
|
||||
const block = last?.kind === 'assistant-step' ? last.data.blocks.findLast(candidate =>
|
||||
(candidate.kind !== 'text' && candidate.kind !== 'reasoning') || candidate.text.trim() !== '') : undefined
|
||||
return block !== undefined && hasAssistantReplyContent([block])
|
||||
})
|
||||
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
|
||||
? node.location.turn
|
||||
: undefined
|
||||
@@ -37,7 +57,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
|
||||
<div
|
||||
className={css.root}
|
||||
data-turn-tail={data.turn}
|
||||
data-actions-reveal={isLatestTurn ? 'always' : 'hover'}
|
||||
data-actions-reveal={endsWithResponse ? 'always' : 'hover'}
|
||||
>
|
||||
{tail}
|
||||
<MessageIconActions
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
.root {
|
||||
min-width: 0;
|
||||
border: 0.5px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 16px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
transition: background-color 100ms ease;
|
||||
}
|
||||
.root:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon { display: inline-flex; flex: none; color: var(--dsw-alias-label-tertiary); }
|
||||
.title { flex: none; font: var(--dsw-font-xs-13); }
|
||||
.time { flex: none; margin-left: auto; color: var(--dsw-alias-label-caption); font: var(--dsw-font-xxs-12); }
|
||||
.chevron, .openChevron { flex: none; color: var(--dsw-alias-label-tertiary); }
|
||||
.openChevron { transform: rotate(180deg); }
|
||||
.body { padding: 0 16px 12px 40px; }
|
||||
.explanation { margin: 8px 0; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxs-12); }
|
||||
.content { max-height: 240px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: var(--dsw-font-xxs-12); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.root { transition: none; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** An independent, expandable notice explaining a non-human Turn trigger. */
|
||||
import { useId, useState, type ComponentType } from 'react'
|
||||
import {
|
||||
IconAgentPresetOutlineRegular, IconAlarmClockOutlineRegular, IconBranchOutlineRegular,
|
||||
IconChevronDownOutlineRegular, IconContextInjectionOutlineRegular, IconCordisPluginOutlineRegular,
|
||||
IconGoalOutlineRegular, IconGlobeOutlineRegular, IconPaperPlaneOutlineRegular, IconQueueOutlineRegular,
|
||||
type IconProps,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatNodeViewProps } from '../contract/slots.ts'
|
||||
import { formatMessageClock } from './message-chrome.ts'
|
||||
import { NoticeBody } from './ContextBody.tsx'
|
||||
import { turnTriggerDetails, type TurnTriggerIcon } from './turn-trigger.ts'
|
||||
import css from './TurnTriggerNodeView.module.css'
|
||||
|
||||
const TRIGGER_ICONS: Record<TurnTriggerIcon, ComponentType<IconProps>> = {
|
||||
request: IconContextInjectionOutlineRegular,
|
||||
goal: IconGoalOutlineRegular,
|
||||
agent: IconPaperPlaneOutlineRegular,
|
||||
team: IconAgentPresetOutlineRegular,
|
||||
subagent: IconAgentPresetOutlineRegular,
|
||||
github: IconBranchOutlineRegular,
|
||||
webhook: IconGlobeOutlineRegular,
|
||||
schedule: IconAlarmClockOutlineRegular,
|
||||
job: IconQueueOutlineRegular,
|
||||
plugin: IconCordisPluginOutlineRegular,
|
||||
}
|
||||
|
||||
/** Render recorded trigger attribution above the whole-Turn disclosure. */
|
||||
export function TurnTriggerNodeView({ node, t }: Pick<ChatNodeViewProps<'turn-trigger'>, 'node' | 't'>) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const bodyId = useId()
|
||||
const details = turnTriggerDetails(node.data)
|
||||
const TriggerIcon = TRIGGER_ICONS[details.icon]
|
||||
const date = new Date(node.data.time)
|
||||
const time = formatMessageClock(node.data.time, t)
|
||||
return (
|
||||
<section className={css.root} data-turn-trigger>
|
||||
<button className={css.header} type="button" aria-expanded={open} aria-controls={bodyId} onClick={() => { setOpen(!open) }}>
|
||||
<span className={css.icon} aria-hidden><TriggerIcon size={14} /></span>
|
||||
<span className={css.title}>{t(details.title)}</span>
|
||||
<time className={css.time} dateTime={date.toISOString()}>{time}</time>
|
||||
<IconChevronDownOutlineRegular size={12} className={open ? css.openChevron : css.chevron} />
|
||||
</button>
|
||||
{open && <div id={bodyId} className={css.body}>
|
||||
<p className={css.explanation}>{t('message.trigger.explanation')}</p>
|
||||
<div className={css.content}><NoticeBody content={node.data.content} source={node.data.source} t={t} /></div>
|
||||
</div>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,10 @@ export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'>
|
||||
/** The elapsed-duration share of the conversation dictionary. */
|
||||
export type RunDurationTranslate =
|
||||
Translate<'duration.seconds' | 'duration.minutes' | 'duration.hours'>
|
||||
|
||||
/** Refresh interval for whole-second live run clocks. */
|
||||
export const LIVE_RUN_CLOCK_INTERVAL_MS = 1000
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
@@ -55,6 +59,24 @@ export function formatRunDuration(ms: number, t: RunDurationTranslate): string {
|
||||
: t('duration.seconds', { seconds })
|
||||
}
|
||||
|
||||
/**
|
||||
* Localized live elapsed time without padded seconds or early rollover.
|
||||
* @param ms - Elapsed duration in milliseconds (negatives clamp to zero).
|
||||
* @param t - Translate seat supplying the duration templates.
|
||||
* @returns Whole seconds without a leading zero; minutes start at 60 seconds
|
||||
* and hours start at exactly 60 minutes.
|
||||
*/
|
||||
export function formatLiveRunDuration(ms: number, t: RunDurationTranslate): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000))
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor(totalSeconds / 60) % 60
|
||||
const seconds = String(totalSeconds % 60)
|
||||
if (hours > 0) return t('duration.hours', { hours, minutes: pad2(minutes), seconds })
|
||||
return minutes > 0
|
||||
? t('duration.minutes', { minutes, seconds })
|
||||
: t('duration.seconds', { seconds })
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode-throughput figure: whole tokens from ten up, one decimal below.
|
||||
* @param tps - Tokens per second.
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { SystemPromptNodeView } from './SystemPromptRow.tsx'
|
||||
import { TurnProcessNodeView } from './TurnProcessNodeView.tsx'
|
||||
import { TurnTailNodeView } from './TurnTailNodeView.tsx'
|
||||
import { TurnTriggerNodeView } from './TurnTriggerNodeView.tsx'
|
||||
|
||||
/**
|
||||
* Register this package's business renderers behind the keyed Chat Node seat.
|
||||
@@ -32,6 +33,8 @@ export function registerChatNodeRenderers(
|
||||
{ name: 'conversation.chat.node', key: 'steering', locale: NS }, UserMessageNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'context', locale: NS }, ContextMessageNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'turn-trigger', locale: NS }, TurnTriggerNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'system-prompt', locale: NS }, SystemPromptNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Localized titles for projected step-process activity. */
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { ProcessActivitySummary } from '../contract/process-groups.ts'
|
||||
|
||||
/**
|
||||
* Compose a closed group's localized title from its top three categories without counts.
|
||||
* @param summary - ranked work and phase evidence for this range.
|
||||
* @param t - Chat namespace translator.
|
||||
* @returns the secondary disclosure title.
|
||||
*/
|
||||
export function processTitle(
|
||||
summary: ProcessActivitySummary,
|
||||
t: ChatViewSlotProps['t'],
|
||||
): string {
|
||||
const labels = summary.counts.slice(0, 3).map(({ kind }) => t(`message.stepProcess.done.${kind}`))
|
||||
const first = labels[0]
|
||||
if (first === undefined) return t('message.stepProcess.done.thinking')
|
||||
const continuation = (label: string): string => label.charAt(0).toLowerCase() + label.slice(1)
|
||||
const second = labels[1]
|
||||
if (second === undefined) return first
|
||||
if (labels.length === 2) {
|
||||
const prefix = t('message.stepProcess.sharedPrefix')
|
||||
const shared = prefix !== '' && first.startsWith(prefix) && second.startsWith(prefix)
|
||||
return t('message.stepProcess.joinTwo', { first, second: continuation(shared ? second.slice(prefix.length) : second) })
|
||||
}
|
||||
const title = [first, ...labels.slice(1).map(continuation)].join(t('message.stepProcess.comma'))
|
||||
return summary.counts.length > 3 ? t('message.stepProcess.more', { title }) : title
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/** Presentation of durable non-human messages claimed to begin a Turn. */
|
||||
import type { ContextMessageNode } from '../contract/snapshot.ts'
|
||||
import type { ChatKey } from '../locale.ts'
|
||||
|
||||
/** Existing primitive glyph selected for a Turn trigger's source family. */
|
||||
export type TurnTriggerIcon = 'agent' | 'github' | 'goal' | 'job' | 'plugin' | 'request' | 'schedule' | 'subagent' | 'team' | 'webhook'
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
||||
}
|
||||
|
||||
function field(source: Record<string, unknown>, key: string): string {
|
||||
return typeof source[key] === 'string' ? source[key] : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe a waking message using its source and recognized producer framing.
|
||||
* @param node - durable context, including the original notification body.
|
||||
* @returns localized title key and source-family icon.
|
||||
*/
|
||||
export function turnTriggerDetails(node: ContextMessageNode): {
|
||||
title: ChatKey
|
||||
icon: TurnTriggerIcon
|
||||
} {
|
||||
const source = record(node.source)
|
||||
const kind = field(source, 'kind')
|
||||
let title: ChatKey = 'message.trigger.request'
|
||||
let icon: TurnTriggerIcon = 'request'
|
||||
switch (kind) {
|
||||
case 'goal': {
|
||||
title = 'message.trigger.goal'
|
||||
icon = 'goal'
|
||||
break
|
||||
}
|
||||
case 'agent-message':
|
||||
title = 'message.trigger.agent'
|
||||
icon = 'agent'
|
||||
break
|
||||
case 'team-message':
|
||||
title = 'message.trigger.team'
|
||||
icon = 'team'
|
||||
break
|
||||
case 'subagent-settled': {
|
||||
title = 'message.trigger.subagent'
|
||||
icon = 'subagent'
|
||||
break
|
||||
}
|
||||
case 'webhook': {
|
||||
const github = field(source, 'provider') === 'github'
|
||||
title = github ? 'message.trigger.github' : 'message.trigger.webhook'
|
||||
icon = github ? 'github' : 'webhook'
|
||||
break
|
||||
}
|
||||
case 'schedule':
|
||||
title = 'message.trigger.schedule'
|
||||
icon = 'schedule'
|
||||
break
|
||||
case 'tool-jobs':
|
||||
title = 'message.trigger.job'
|
||||
icon = 'job'
|
||||
break
|
||||
case 'cordis-host-runner':
|
||||
title = 'message.trigger.plugin'
|
||||
icon = 'plugin'
|
||||
break
|
||||
default:
|
||||
// Custom sources remain visible without attributing unrecorded identity or success.
|
||||
break
|
||||
}
|
||||
return { title, icon }
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useLayoutEffect, useState } from 'react'
|
||||
import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
import type { ChatScrollPosition, ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { TurnRailItem } from './turn-rail-items.ts'
|
||||
import type { ChatReading, ReadingSample } from './use-chat-reading.ts'
|
||||
import type { ChatViewport } from './use-chat-viewport.ts'
|
||||
@@ -25,8 +25,6 @@ interface TurnJump {
|
||||
/** Owns one replaceable turn jump and the anchor retained while history loads. */
|
||||
export class ChatNavigation {
|
||||
private jump: TurnJump | null = null
|
||||
private anchor: ChatScrollPosition | null = null
|
||||
private committed: Pick<ChatNavigationInput, 'firstSeq' | 'loadingOlder'>
|
||||
private settleFrame: number | null = null
|
||||
|
||||
constructor(
|
||||
@@ -34,9 +32,7 @@ export class ChatNavigation {
|
||||
private readonly reading: ChatReading,
|
||||
private input: ChatNavigationInput,
|
||||
private readonly onBusyTurn: (turn: number | null) => void,
|
||||
) {
|
||||
this.committed = input
|
||||
}
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Adopt committed history availability without starting a request.
|
||||
@@ -44,10 +40,9 @@ export class ChatNavigation {
|
||||
*/
|
||||
setInput(input: ChatNavigationInput): void { this.input = input }
|
||||
|
||||
/** Cancel navigation and establish the current history window as the baseline. */
|
||||
/** Cancel navigation when opening a Chat view. */
|
||||
reset(): void {
|
||||
this.cancel()
|
||||
this.committed = this.input
|
||||
}
|
||||
|
||||
/** Cancel local callbacks; late history completions cannot revive a task. */
|
||||
@@ -64,7 +59,7 @@ export class ChatNavigation {
|
||||
private clearTask(): void {
|
||||
this.cancelFrame()
|
||||
this.jump = null
|
||||
this.anchor = null
|
||||
this.viewport.stopPreserving()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,15 +68,15 @@ export class ChatNavigation {
|
||||
*/
|
||||
readonly navigateToTurn = (item: TurnRailItem): void => {
|
||||
if (item.anchor.kind === 'loaded') {
|
||||
this.cancel()
|
||||
const landing = this.viewport.scrollToTurn(item.turn)
|
||||
if (landing === null) return
|
||||
this.cancel()
|
||||
this.reading.acceptNavigation(landing)
|
||||
this.anchor = this.input.loadingOlder ? landing.position : null
|
||||
if (this.input.loadingOlder) this.viewport.beginPreserving(landing.position)
|
||||
return
|
||||
}
|
||||
this.cancelFrame()
|
||||
this.anchor = this.viewport.capturePosition()
|
||||
this.cancel()
|
||||
this.viewport.beginPreserving()
|
||||
this.reading.pauseFollowing()
|
||||
const jump: TurnJump = {
|
||||
turn: item.turn,
|
||||
@@ -97,7 +92,9 @@ export class ChatNavigation {
|
||||
|
||||
/** Request one older page while retaining the current semantic position. */
|
||||
readonly loadEarlier = (): void => {
|
||||
this.anchor = this.viewport.capturePosition()
|
||||
this.cancel()
|
||||
this.viewport.beginPaging()
|
||||
this.reading.pauseFollowing()
|
||||
this.input.loadOlder()
|
||||
}
|
||||
|
||||
@@ -107,27 +104,27 @@ export class ChatNavigation {
|
||||
*/
|
||||
readerSampled(sample: ReadingSample): void {
|
||||
if (sample.movedByReader && this.jump?.landing === 'landed') this.jump.landing = 'interrupted'
|
||||
if (sample.followingTail) this.anchor = null
|
||||
else if (this.anchor !== null && sample.position !== null) this.anchor = sample.position
|
||||
if (sample.followingTail || sample.movedByReader) this.viewport.stopPreserving()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a newly committed history head with any retained anchor.
|
||||
* @returns whether a prepended window consumed the current position-preservation operation.
|
||||
* Preserve one paging anchor after a commit or a later size change, regardless of head identity.
|
||||
* @returns whether the retained anchor handled the layout change.
|
||||
*/
|
||||
contentCommitted(): boolean {
|
||||
const previous = this.committed
|
||||
this.committed = this.input
|
||||
if (this.anchor !== null && this.input.firstSeq !== null && previous.firstSeq !== null
|
||||
&& this.input.firstSeq < previous.firstSeq) {
|
||||
if (this.landJump(false)) return true
|
||||
const landing = this.viewport.preserve(this.anchor)
|
||||
this.anchor = this.jump === null ? null : landing?.position ?? null
|
||||
if (landing !== null) this.reading.preservePosition(landing)
|
||||
return true
|
||||
if (!this.viewport.preserving || this.reading.pending) return false
|
||||
if (this.landJump(false)) return true
|
||||
const landing = this.viewport.preserve()
|
||||
if (landing === null) return false
|
||||
this.reading.preservePosition(landing)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Retarget a still-loading page only after inner or outer reader scrolling ends. */
|
||||
readerSettled(): void {
|
||||
if (this.input.loadingOlder && this.jump === null && !this.viewport.preserving && !this.reading.followingTail) {
|
||||
this.viewport.beginPreserving()
|
||||
}
|
||||
if (previous.loadingOlder && !this.input.loadingOlder && this.jump === null) this.anchor = null
|
||||
return false
|
||||
}
|
||||
|
||||
/** Land, retry, or complete the current jump against the committed window. */
|
||||
@@ -143,7 +140,7 @@ export class ChatNavigation {
|
||||
const uncovered = this.input.firstSeq === null || this.input.firstSeq > jump.seq
|
||||
if (uncovered && this.input.hasMore && jump.repageHead !== this.input.firstSeq) {
|
||||
jump.repageHead = this.input.firstSeq
|
||||
this.anchor = this.viewport.capturePosition()
|
||||
this.viewport.beginPreserving()
|
||||
this.request(jump)
|
||||
return
|
||||
}
|
||||
@@ -164,7 +161,7 @@ export class ChatNavigation {
|
||||
this.reading.acceptNavigation(landing)
|
||||
if (settle) this.cancel()
|
||||
else {
|
||||
this.anchor = landing.position
|
||||
this.viewport.beginPreserving(landing.position)
|
||||
jump.landing = 'landed'
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -53,8 +53,10 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
const current = content.current.input
|
||||
const previous = content.current.applied
|
||||
const ownInput = (current.lastIsUser && current.lastKey !== previous?.lastKey)
|
||||
|| (current.steeringId !== null && current.steeringId !== previous?.steeringId)
|
||||
|| (current.submissionId !== null && current.submissionId !== previous?.submissionId)
|
||||
|| (current.steeringId !== null && current.steeringId !== previous?.steeringId
|
||||
&& current.steeringId !== previous?.submissionId)
|
||||
|| (current.submissionId !== null && current.submissionId !== previous?.submissionId
|
||||
&& current.submissionId !== previous?.steeringId)
|
||||
if (reading.pending && !ownInput) return
|
||||
content.current.applied = current
|
||||
if (current.ready && !content.current.opened) {
|
||||
@@ -63,6 +65,11 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
reading.restore()
|
||||
return
|
||||
}
|
||||
if (ownInput) {
|
||||
navigation.cancel()
|
||||
reading.followTail()
|
||||
return
|
||||
}
|
||||
if (navigation.contentCommitted()) {
|
||||
navigation.reconcile()
|
||||
return
|
||||
@@ -71,7 +78,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
|| current.firstSeq !== previous.firstSeq || current.lastKey !== previous.lastKey
|
||||
|| current.order.length !== previous.order.length || current.running !== previous.running
|
||||
|| current.steeringId !== previous.steeringId || current.submissionId !== previous.submissionId
|
||||
if (ownInput || (tipChanged && reading.followingTail)) {
|
||||
if (tipChanged && reading.followingTail) {
|
||||
navigation.cancel()
|
||||
reading.followTail()
|
||||
} else navigation.reconcile()
|
||||
@@ -80,9 +87,13 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
useLayoutEffect(() => {
|
||||
const disconnectViewport = viewport.connect({
|
||||
scroll: reading.onScroll,
|
||||
scrollEnd: reading.onScrollEnd,
|
||||
scrollEnd: () => {
|
||||
reading.onScrollEnd()
|
||||
navigation.readerSettled()
|
||||
},
|
||||
interact: () => { navigation.cancel() },
|
||||
resize: () => {
|
||||
reading.onResize()
|
||||
if (!navigation.contentCommitted()) reading.onResize()
|
||||
navigation.reconcile()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -27,14 +27,25 @@ interface ViewportEvents {
|
||||
scroll: (event: ViewportScroll) => void
|
||||
scrollEnd: () => void
|
||||
resize: () => void
|
||||
interact: () => void
|
||||
}
|
||||
|
||||
interface ViewportElements {
|
||||
readonly list: HTMLElement
|
||||
readonly column: HTMLElement
|
||||
readonly scroller: HTMLElement
|
||||
readonly composer: HTMLElement | null
|
||||
}
|
||||
|
||||
interface PagingPosition {
|
||||
readonly row: HTMLElement
|
||||
readonly position: ChatScrollPosition
|
||||
readonly group: { readonly body: HTMLElement; readonly content: HTMLElement; readonly top: number } | null
|
||||
}
|
||||
|
||||
const READING_INTENTS = ['wheel', 'touchstart', 'pointerdown', 'keydown', 'beforematch'] as const
|
||||
const SCROLL_KEYS = new Set(['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '])
|
||||
|
||||
/** Owns one Chat scrollport's DOM operations, event listeners, and size observer. */
|
||||
export class ChatViewport {
|
||||
private elements: ViewportElements | null = null
|
||||
@@ -42,20 +53,22 @@ export class ChatViewport {
|
||||
private events: ViewportEvents | null = null
|
||||
private turns: ReturnType<ChatSnapshot['navigation']['items']> = []
|
||||
private observation: { top: number; landing: ViewportLanding | null } = { top: 0, landing: null }
|
||||
private paging: PagingPosition | null = null
|
||||
|
||||
/**
|
||||
* Bind to the containing scrollport and observe content and viewport sizes.
|
||||
* @param list - Chat root inside an optional shared conversation scrollport.
|
||||
* @param column - content column whose size changes invalidate cached landings.
|
||||
* @param column - ordered outer Node/Group boxes; its size changes invalidate cached landings.
|
||||
*/
|
||||
attach(list: HTMLElement, column: HTMLElement): void {
|
||||
this.detach()
|
||||
const scroller = list.closest<HTMLElement>('[data-conversation-scroll]') ?? list
|
||||
const composer = scroller.querySelector<HTMLElement>('[data-composer-seat]')
|
||||
const elements = { list, scroller, composer }
|
||||
const elements = { list, column, scroller, composer }
|
||||
this.elements = elements
|
||||
scroller.addEventListener('scroll', this.onScroll, { passive: true })
|
||||
scroller.addEventListener('scrollend', this.onScrollEnd, { passive: true })
|
||||
scroller.addEventListener('scrollend', this.onScrollEnd, { passive: true, capture: true })
|
||||
for (const type of READING_INTENTS) scroller.addEventListener(type, this.onIntent, { passive: true, capture: true })
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
this.observer = new ResizeObserver(() => {
|
||||
if (this.elements !== elements) return
|
||||
@@ -70,8 +83,10 @@ export class ChatViewport {
|
||||
|
||||
/** Disconnect DOM resources and clear observations for the detached view. */
|
||||
detach(): void {
|
||||
this.stopPreserving()
|
||||
this.elements?.scroller.removeEventListener('scroll', this.onScroll)
|
||||
this.elements?.scroller.removeEventListener('scrollend', this.onScrollEnd)
|
||||
this.elements?.scroller.removeEventListener('scrollend', this.onScrollEnd, true)
|
||||
for (const type of READING_INTENTS) this.elements?.scroller.removeEventListener(type, this.onIntent, true)
|
||||
this.observer?.disconnect()
|
||||
this.observer = null
|
||||
this.elements = null
|
||||
@@ -139,19 +154,19 @@ export class ChatViewport {
|
||||
return { top: scroller.scrollTop, height, floor: Math.max(0, scroller.scrollHeight - height) }
|
||||
}
|
||||
|
||||
private anchor(key: string): HTMLElement | null {
|
||||
private anchor(key: string, identity: 'position' | 'node' = 'position'): HTMLElement | null {
|
||||
if (this.elements === null) return null
|
||||
// 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 (row.dataset.chatAnchorKey === key || (identity === 'node' && row.dataset.chatNodeKey === key)) return row
|
||||
if (nodePart === null && row.dataset.chatNodeKey === key) nodePart = row
|
||||
}
|
||||
return nodePart
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the reader's position using visible transcript rows.
|
||||
* Capture visible transcript content, excluding Turn controls that relocate when history expands.
|
||||
* @returns a visible semantic anchor, or null when no anchor can be resolved.
|
||||
*/
|
||||
capturePosition(): ChatScrollPosition | null {
|
||||
@@ -167,13 +182,18 @@ export class ChatViewport {
|
||||
const right = Math.min(viewport.right, content.right)
|
||||
for (const element of document.elementsFromPoint(left + Math.max(0, right - left) / 2, viewport.top + 1)) {
|
||||
const row = element instanceof HTMLElement ? element.closest<HTMLElement>('[data-chat-anchor-key]') : null
|
||||
if (row !== null && list.contains(row)) { anchor = row; break }
|
||||
if (row !== null && row.dataset.chatFlowKind !== 'turn-process' && list.contains(row)) {
|
||||
// An open group's header stays put while older members enter above its body content.
|
||||
anchor = row.dataset.chatGroupKey === undefined ? row
|
||||
: row.querySelector<HTMLElement>('[data-step-process-content] > [data-chat-anchor-key]:not(:empty):not([hidden]):not([hidden] *)') ?? row
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (anchor === null) {
|
||||
const rows = list.querySelectorAll<HTMLElement>(
|
||||
':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] *)',
|
||||
'[data-chat-flow-key]:not([data-chat-group-key]):not([data-chat-flow-kind="turn-process"])'
|
||||
+ ':not(:empty):not([hidden]):not([hidden] *)',
|
||||
)
|
||||
let low = 0
|
||||
let high = rows.length
|
||||
@@ -194,9 +214,11 @@ export class ChatViewport {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active turn, reusing a known landing when its position is unchanged.
|
||||
* Approximate the active Turn by binary-searching outer Node/Group boxes.
|
||||
* Gaps retain the last visited Turn candidate, not necessarily the immediate predecessor.
|
||||
* A known landing bypasses measurement while its position is unchanged.
|
||||
* @param metrics - reusable scroll metrics; omitted callers request a fresh read.
|
||||
* @returns the loaded turn at the reading line, or null while detached or empty.
|
||||
* @returns the Turn near the reading line, or null while detached or empty.
|
||||
*/
|
||||
readVisibleTurn(metrics = this.metrics()): number | null {
|
||||
const knownTurn = this.observation.landing?.turn
|
||||
@@ -205,30 +227,22 @@ export class ChatViewport {
|
||||
const first = this.turns[0]
|
||||
if (elements === null || metrics === null || first === undefined) return null
|
||||
const line = elements.scroller.getBoundingClientRect().top + Math.min(96, metrics.height * 0.2)
|
||||
const content = elements.list.getBoundingClientRect()
|
||||
let reading: number | null = null
|
||||
if (typeof document.elementsFromPoint === 'function' && content.width > 0) {
|
||||
for (const element of document.elementsFromPoint(content.left + content.width / 2, line)) {
|
||||
const row = element instanceof HTMLElement ? element.closest<HTMLElement>('[data-chat-turn]') : null
|
||||
const turn = Number(row?.dataset.chatTurn)
|
||||
if (row !== null && elements.list.contains(row) && Number.isSafeInteger(turn)) { reading = turn; break }
|
||||
}
|
||||
}
|
||||
if (reading === null) {
|
||||
for (const row of elements.list.querySelectorAll<HTMLElement>('[data-chat-turn]')) {
|
||||
if (row.getBoundingClientRect().top > line) break
|
||||
const turn = Number(row.dataset.chatTurn)
|
||||
const rows = elements.column.children
|
||||
let low = 0
|
||||
let high = rows.length
|
||||
let reading = first.turn
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1
|
||||
const row = rows[middle] as Element
|
||||
if (row.getBoundingClientRect().top > line) high = middle
|
||||
else {
|
||||
const value = row.getAttribute('data-chat-turn')
|
||||
const turn = value === null ? NaN : Number(value)
|
||||
if (Number.isSafeInteger(turn)) reading = turn
|
||||
low = middle + 1
|
||||
}
|
||||
}
|
||||
let result = first.turn
|
||||
if (reading !== null) {
|
||||
for (const item of this.turns) {
|
||||
if (item.turn > reading) break
|
||||
result = item.turn
|
||||
}
|
||||
}
|
||||
return result
|
||||
return reading
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,7 +254,7 @@ export class ChatViewport {
|
||||
scrollToTurn(turn: number): ViewportLanding | null {
|
||||
const item = this.turns.find(candidate => candidate.turn === turn)
|
||||
if (item === undefined) return null
|
||||
const row = this.anchor(item.anchorKey)
|
||||
const row = this.anchor(item.anchorKey, 'node')
|
||||
return row === null ? null : this.align(row, 24, turn)
|
||||
}
|
||||
|
||||
@@ -270,14 +284,97 @@ export class ChatViewport {
|
||||
return metrics === null ? null : this.write(position.scrollTop, metrics, null)
|
||||
}
|
||||
|
||||
/** Retain the first eligible transcript seat in DOM order; selection reads no geometry. */
|
||||
beginPaging(): void {
|
||||
this.stopPreserving()
|
||||
const row = this.elements?.list.querySelector<HTMLElement>(
|
||||
'[data-chat-paging-anchor]:not(:empty):not([hidden]):not([hidden] *)',
|
||||
)
|
||||
if (row != null) this.retain(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve an existing anchor without falling back to an unrelated row.
|
||||
* @param position - semantic anchor to retain after content changes.
|
||||
* @returns the compensated landing, or null if the anchor is unavailable.
|
||||
* Retain one old row and its inner/outer offsets for paging and later content growth.
|
||||
* @param position - an explicit landing to retain; omitted callers capture the current reading position.
|
||||
*/
|
||||
preserve(position: ChatScrollPosition): ViewportLanding | null {
|
||||
beginPreserving(position: ChatScrollPosition | null = this.capturePosition()): void {
|
||||
this.stopPreserving()
|
||||
if (position === null) return
|
||||
const row = this.anchor(position.anchorKey)
|
||||
return row === null ? null : this.align(row, position.anchorTop, null)
|
||||
if (row === null) return
|
||||
this.retain(row, position)
|
||||
}
|
||||
|
||||
private retain(row: HTMLElement, position?: ChatScrollPosition, groupTop?: number): PagingPosition | null {
|
||||
const elements = this.elements
|
||||
const key = row.dataset.chatAnchorKey
|
||||
if (elements === null || key === undefined) return null
|
||||
const previous = this.paging?.group
|
||||
if (previous != null) this.observer?.unobserve(previous.content)
|
||||
const top = row.getBoundingClientRect().top
|
||||
const body = row.closest<HTMLElement>('[data-step-process-body]')
|
||||
const content = body?.querySelector<HTMLElement>('[data-step-process-content]')
|
||||
const group = body === null || content == null ? null
|
||||
: { body, content, top: groupTop ?? top - body.getBoundingClientRect().top }
|
||||
this.paging = {
|
||||
row, group,
|
||||
position: position ?? {
|
||||
anchorKey: key,
|
||||
anchorTop: top - elements.scroller.getBoundingClientRect().top,
|
||||
scrollTop: elements.scroller.scrollTop,
|
||||
},
|
||||
}
|
||||
if (group !== null) this.observer?.observe(group.content)
|
||||
return this.paging
|
||||
}
|
||||
|
||||
/** Release paging ownership and its content-size observation. */
|
||||
stopPreserving(): void {
|
||||
const group = this.paging?.group
|
||||
if (group != null) this.observer?.unobserve(group.content)
|
||||
this.paging = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose retained paging ownership to navigation and resize policy.
|
||||
* @returns whether a paging row is retained for subsequent layout changes.
|
||||
*/
|
||||
get preserving(): boolean { return this.paging !== null }
|
||||
|
||||
/**
|
||||
* Compensate inner scrolling first, then the outer scrollport, within their actual scroll ranges.
|
||||
* @returns the actual landing, or null when no visible retained row remains.
|
||||
*/
|
||||
preserve(): ViewportLanding | null {
|
||||
let paging = this.paging
|
||||
const elements = this.elements
|
||||
if (paging === null || elements === null) return null
|
||||
if (!elements.list.contains(paging.row)) {
|
||||
// Segmentation can remount the same semantic row under another group.
|
||||
const replacement = this.anchor(paging.position.anchorKey)
|
||||
if (replacement === null) {
|
||||
this.stopPreserving()
|
||||
return null
|
||||
}
|
||||
paging = this.retain(replacement, paging.position, paging.group?.top)
|
||||
if (paging === null) return null
|
||||
}
|
||||
const { row, group, position } = paging
|
||||
if (row.closest('[hidden]') !== null || row.matches(':empty')) {
|
||||
this.stopPreserving()
|
||||
return null
|
||||
}
|
||||
if (group !== null && group.body.contains(row)) {
|
||||
const top = row.getBoundingClientRect().top - group.body.getBoundingClientRect().top
|
||||
const floor = Math.max(0, group.body.scrollHeight - group.body.clientHeight)
|
||||
const target = Math.max(0, Math.min(floor, group.body.scrollTop + top - group.top))
|
||||
if (group.body.scrollTop !== target) group.body.scrollTop = target
|
||||
}
|
||||
const metrics = this.metrics()
|
||||
if (metrics === null) return null
|
||||
const top = row.getBoundingClientRect().top - elements.scroller.getBoundingClientRect().top
|
||||
const target = metrics.top + top - position.anchorTop
|
||||
return this.write(target, metrics, null, { key: position.anchorKey, top })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -321,12 +418,27 @@ export class ChatViewport {
|
||||
if (this.elements === null || event.target !== this.elements.scroller) return
|
||||
if (this.observation.landing !== null && this.elements.scroller.scrollTop === this.observation.top) return
|
||||
this.invalidate()
|
||||
if (this.paging !== null) {
|
||||
this.events?.resize()
|
||||
return
|
||||
}
|
||||
const scroll = this.readScroll()
|
||||
if (scroll !== null) this.events?.scroll(scroll)
|
||||
}
|
||||
|
||||
private readonly onScrollEnd = (event: Event): void => {
|
||||
if (event.target === this.elements?.scroller) this.events?.scrollEnd()
|
||||
if (event.target === this.elements?.scroller
|
||||
|| (event.target instanceof HTMLElement && event.target.hasAttribute('data-step-process-body'))) this.events?.scrollEnd()
|
||||
}
|
||||
|
||||
private readonly onIntent = (event: Event): void => {
|
||||
if (event.type === 'keydown' || event.type === 'pointerdown') {
|
||||
if (event.target instanceof Element && event.target.closest('[data-composer-seat]') !== null) return
|
||||
if (event.type === 'keydown' && (!(event instanceof KeyboardEvent) || !SCROLL_KEYS.has(event.key))) return
|
||||
}
|
||||
if (this.paging === null) return
|
||||
this.stopPreserving()
|
||||
this.events?.interact()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/** Bind independent disclosure state to a Chat seat's reset source. */
|
||||
import { useCallback, useState, useSyncExternalStore } from 'react'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { UseDisclosure } from '../contract/slots.ts'
|
||||
|
||||
/**
|
||||
* Own one initially collapsed disclosure without an external subscription.
|
||||
* @param version - reset generation; unchanged generations retain local open state.
|
||||
* @returns the open state, an explicit setter, and a toggle action.
|
||||
*/
|
||||
export function useDisclosure(version = 0): ReturnType<UseDisclosure> {
|
||||
const [expandedVersion, setExpandedVersion] = useState<number | null>(null)
|
||||
const expanded = expandedVersion === version
|
||||
const setExpanded = useCallback((open: boolean) => {
|
||||
setExpandedVersion(open ? version : null)
|
||||
}, [version])
|
||||
const toggle = useCallback(() => {
|
||||
setExpandedVersion(previous => previous === version ? null : version)
|
||||
}, [version])
|
||||
return { expanded, setExpanded, toggle }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a Hook without subscribing until a component calls it.
|
||||
* @param reset - stable source whose version advances when the seat is hidden by its Turn.
|
||||
* @returns a Hook with independent open state for each invocation.
|
||||
*/
|
||||
export function bindDisclosure(reset: ObservableSnapshot<number>): UseDisclosure {
|
||||
const subscribe = (listener: () => void) => reset.subscribe(listener)
|
||||
const getSnapshot = () => reset.getSnapshot()
|
||||
return function useBoundDisclosure() {
|
||||
const version = useSyncExternalStore(subscribe, getSnapshot)
|
||||
return useDisclosure(version)
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,13 @@
|
||||
import type { ChatNode } from './chat-nodes.ts'
|
||||
|
||||
/**
|
||||
* Exclude system prompts and permission commands from visible Chat rows.
|
||||
* Exclude system prompts, ordinary Context, and permission commands from visible Chat rows.
|
||||
* @param node - projected Chat node.
|
||||
* @returns whether the node contributes a visible Chat row.
|
||||
*/
|
||||
export function isVisibleChatNode(node: ChatNode): boolean {
|
||||
return node.visibility === 'visible'
|
||||
&& node.kind !== 'system-prompt'
|
||||
&& node.kind !== 'context'
|
||||
&& !(node.kind === 'command' && node.data.name === 'permission')
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Business data for Chat process groups; source Nodes remain in the Chat store. */
|
||||
export type ProcessActivity = 'read' | 'search' | 'edit' | 'commands' | 'code'
|
||||
| 'webSearch' | 'webFetch' | 'subagents' | 'plan' | 'questions' | 'tools'
|
||||
|
||||
/** Distinct-call category ranking and the current live task detail. */
|
||||
export interface ProcessActivitySummary {
|
||||
readonly counts: readonly { readonly kind: ProcessActivity; readonly count: number }[]
|
||||
readonly running: ProcessActivity | undefined
|
||||
readonly runningDetail: string
|
||||
}
|
||||
|
||||
/** Presentation facts for one group between independent replies or input. */
|
||||
export interface ProcessGroupData {
|
||||
readonly turn: number
|
||||
readonly closed: boolean
|
||||
readonly summary: ProcessActivitySummary
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ConversationGroupDataMap {
|
||||
chat: ProcessGroupData
|
||||
}
|
||||
}
|
||||
@@ -80,9 +80,30 @@ export type UseChatNodeTurnData = <Key extends Extract<keyof ConversationTurnDat
|
||||
key: Key,
|
||||
) => Readonly<ConversationTurnDataMap[Key]> | undefined
|
||||
|
||||
/** Slot-level Hook factory for keyed Chat renderers. */
|
||||
export interface ChatNodeTurnDataInjected {
|
||||
hooks: { turnData: SlotHookFactory<'conversation.chat.node', UseChatNodeTurnData> }
|
||||
/**
|
||||
* Subscribe to enclosing-Turn resets and own one initially collapsed disclosure.
|
||||
* Each invocation has independent open state; display-mode changes do not reset it.
|
||||
* @returns the current open state, explicit setter, and toggle action.
|
||||
*/
|
||||
export type UseDisclosure = () => {
|
||||
readonly expanded: boolean
|
||||
/** @param open - whether this disclosure is expanded. */
|
||||
readonly setExpanded: (open: boolean) => void
|
||||
readonly toggle: () => void
|
||||
}
|
||||
|
||||
/** Stable sources bound to one rendered Chat Node. */
|
||||
export interface ChatNodeHookContext {
|
||||
readonly turnData: ConversationLocationDataStore<ConversationTurnDataMap> | undefined
|
||||
readonly disclosureReset: ObservableSnapshot<number>
|
||||
}
|
||||
|
||||
/** Slot-level Hook factories for keyed Chat renderers. */
|
||||
export interface ChatNodeInjected {
|
||||
hooks: {
|
||||
turnData: SlotHookFactory<'conversation.chat.node', UseChatNodeTurnData>
|
||||
disclosure: SlotHookFactory<'conversation.chat.node', UseDisclosure>
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable owner currency delivered to a keyed Chat renderer. */
|
||||
@@ -110,6 +131,8 @@ export interface ChatNodeOwnerProps {
|
||||
|
||||
/** Shared presentation state for one Turn-process answer generation. */
|
||||
export interface TurnProcessOwnerProps {
|
||||
/** Process content eligible to share one Turn-level disclosure. */
|
||||
readonly hasContent: boolean
|
||||
readonly spec: TurnProcessSpec
|
||||
readonly foldable: boolean
|
||||
readonly open: boolean
|
||||
@@ -219,8 +242,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
scope: 'session'
|
||||
owner: ChatNodeOwnerProps
|
||||
keyProps: { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }
|
||||
hookContext: ConversationLocationDataStore<ConversationTurnDataMap> | undefined
|
||||
inject: ChatNodeTurnDataInjected
|
||||
hookContext: ChatNodeHookContext
|
||||
inject: ChatNodeInjected
|
||||
}
|
||||
/**
|
||||
* Renderer for one consecutive group of durable message images. The owner
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type {
|
||||
ConversationNode, ConversationTimelineSnapshot, PartialAssistant, RunningToolCall,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ChatConversationViewNode } from './chat-nodes.ts'
|
||||
import type { ChatConversationViewNode, ChatNodeDataMap, ChatNodeKind } from './chat-nodes.ts'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { TurnProcessSpec } from './turn-process.ts'
|
||||
|
||||
export type {
|
||||
@@ -34,6 +35,14 @@ export interface ChatNodeStore {
|
||||
get(key: string): ChatConversationViewNode | undefined
|
||||
/** @param key - stable Conversation Context key. @returns its identity-stable observable source. */
|
||||
source(key: string): ChatNodeSource
|
||||
/**
|
||||
* Observe one Turn's data for a single Node kind, including hidden Nodes, in anchor order.
|
||||
* Other Turns and kinds do not notify this source.
|
||||
* @param turn - resolved owning Turn.
|
||||
* @param kind - business Node kind.
|
||||
* @returns an identity-stable source whose array changes only with its members.
|
||||
*/
|
||||
turnDataSource<Kind extends ChatNodeKind>(turn: number, kind: Kind): ObservableSnapshot<readonly ChatNodeDataMap[Kind][]>
|
||||
/** @param key - stable Conversation Context key. @returns its Turn-process presentation source. */
|
||||
processSource(key: string): ChatNodeProcessSource
|
||||
/** @returns all currently materialized Nodes without imposing render order. */
|
||||
@@ -78,6 +87,8 @@ export interface ChatTurnProcessPresentation {
|
||||
readonly turnStarted: boolean
|
||||
readonly turnClosed: boolean
|
||||
readonly hasExternalProcess: boolean
|
||||
/** A visible input after process output prevents one disclosure from hiding its surrounding groups. */
|
||||
readonly hasInterleavedInput: boolean
|
||||
readonly compactAnswer: boolean
|
||||
}
|
||||
|
||||
@@ -116,6 +127,10 @@ const EMPTY_NODE_PROCESS_SOURCE: ChatNodeProcessSource = {
|
||||
getSnapshot: () => undefined,
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
const EMPTY_TURN_NODE_SOURCE: ObservableSnapshot<readonly never[]> = {
|
||||
getSnapshot: () => EMPTY_LIST,
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
|
||||
/** Empty Chat target used before a view builder is registered. */
|
||||
export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = {
|
||||
@@ -123,6 +138,7 @@ export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = {
|
||||
nodes: {
|
||||
get: () => undefined,
|
||||
source: () => EMPTY_NODE_SOURCE,
|
||||
turnDataSource: () => EMPTY_TURN_NODE_SOURCE,
|
||||
processSource: () => EMPTY_NODE_PROCESS_SOURCE,
|
||||
values: () => EMPTY_LIST,
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ const TURN_PROCESS_INDEPENDENT_KIND_LIST = [
|
||||
'system-prompt',
|
||||
'user',
|
||||
'steering',
|
||||
'turn-trigger',
|
||||
'turn-process',
|
||||
'turn-error',
|
||||
'turn-max-tokens',
|
||||
@@ -59,3 +60,15 @@ export function sameTurnProcessSpec(left: TurnProcessSpec, right: TurnProcessSpe
|
||||
export function isSubagentDelegationTool(name: string): boolean {
|
||||
return name === 'subagent' || name.startsWith('subagent_')
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep live, stopped, and failed Turns open.
|
||||
* @param node - Node carrying the owning Turn.
|
||||
* @returns whether whole-Turn collapse is unavailable.
|
||||
*/
|
||||
export function turnProcessAlwaysOpen(node: ChatNode | undefined): boolean {
|
||||
const location = node?.location
|
||||
if (location?.kind !== 'turn' && location?.kind !== 'step') return false
|
||||
const reason = location.turn.end?.data.reason.kind
|
||||
return location.turn.status === 'open' || reason === 'aborted' || reason === 'error'
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-chat/src/client/conversation-nodes/README.md
|
||||
README.md: 9dca6245db9383f8632f45b514454ad3ef201f35
|
||||
README.zh.md: b0cc2cfa51306a614da36cb9f21734136864932a
|
||||
@@ -0,0 +1,290 @@
|
||||
---
|
||||
description: "Detailed business rules for Chat process grouping and activity summaries."
|
||||
---
|
||||
# Chat conversation node rules
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
A Turn shows an answer and lets the reader inspect the work behind it. Start with the complete example below, then read the folding and display rules to understand what is visible. The later sections define exact group boundaries, every tool-name category, counting, and live-detail selection.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [How one Turn becomes a transcript](#reading-model)
|
||||
- [Whole-Turn folding](#whole-turn-folding)
|
||||
- [Display modes](#display-modes)
|
||||
- [Process grouping](#process-grouping)
|
||||
- [Activity summaries and tool rules](#activity-summaries)
|
||||
|
||||
-----
|
||||
|
||||
<a id="reading-model"></a>
|
||||
## How one Turn becomes a transcript
|
||||
|
||||
Grouping, counts, display modes, and whole-Turn folding answer different questions. Grouping decides which process rows belong together. Counts summarize work inside an existing group. Display modes decide how much of each group to show. Without an intervening input, whole-Turn folding can hide the entire process across several groups and intermediate replies.
|
||||
|
||||
### A complete example
|
||||
|
||||
Suppose the assistant thinks, reads a file, runs a command, posts a progress reply, then thinks and runs code before giving its final answer. After completion, that Turn contains:
|
||||
|
||||
- User input.
|
||||
- Whole-Turn control.
|
||||
- Process content controlled by the whole-Turn control:
|
||||
- Group G1: reasoning, `read`, `bash`.
|
||||
- Intermediate reply: outside both groups, but still part of the Turn's process.
|
||||
- Group G2: reasoning, `run_code`.
|
||||
- Final response, outside the process fold.
|
||||
- Completed-turn footer.
|
||||
|
||||
Every reply separates secondary groups; only the final answer is protected from whole-Turn folding. An intermediate reply can be an independent row and still disappear when the Turn is folded. Reasoning attached to the final answer remains process content.
|
||||
|
||||
| Question | Deciding rule | Result in this example |
|
||||
|---|---|---|
|
||||
| Which rows belong together? | Group boundaries | The reply separates G1 and G2; changing from `read` to `bash` does not. |
|
||||
| What did a group do? | Category counts | G1 has `read=1, commands=1`; G2 has `code=1`. Reasoning adds no tool count. |
|
||||
| What does its title say? | Group state and summary | Running groups use their latest running activity; closed groups use their highest-count categories. Counts never decide folding. |
|
||||
| How much detail is visible? | Display mode and manual group opening | Completed Turns retain group headers and manual bodies in every mode; Expanded directly shows group bodies only in running Turns. |
|
||||
| Is the process visible at all? | Whole-Turn opening | Closing the Turn hides G1, the intermediate reply, and G2 together. |
|
||||
|
||||
### What the reader sees
|
||||
|
||||
Assume the Turn above completed normally, with no inner disclosure manually opened:
|
||||
|
||||
| Viewing state | Visible content |
|
||||
|---|---|
|
||||
| Whole Turn collapsed, any mode | Input, whole-Turn control, final response, and footer. No process headers, bodies, or intermediate reply. |
|
||||
| Whole Turn open, Compact or Detailed | G1/G2 headers, the intermediate reply, and final response. Group bodies start collapsed. |
|
||||
| Whole Turn and G1 open, Detailed | G1's reasoning and tool rows with the settled reasoning preview; G2 remains a header. Full reasoning/tool bodies are still manual. |
|
||||
| Whole Turn open, Expanded | G1/G2 headers, intermediate reply, and final response. Completed Turns' group bodies still require manual opening. |
|
||||
|
||||
Visibility applies from outside inward: whole Turn → secondary group → individual reasoning/tool disclosure. Opening an inner layer cannot bypass a closed outer layer. Category changes never open or close a layer. Mode changes preserve membership and manual opening choices.
|
||||
|
||||
If steering, a User message, or a trigger notice follows process output, the Turn offers only individual group disclosures, not whole-Turn collapse. In Compact/Detailed, `G1 → steering 1 → G2 → steering 2 → G3` retains all three headers and both inputs in that order, with intermediate replies visible; opening one group reveals only its body. Expanded directly shows group bodies only while the Turn runs; completed Turns retain headers and manual disclosure. The Turn control still shows duration or status, without a collapse action.
|
||||
|
||||
-----
|
||||
|
||||
<a id="whole-turn-folding"></a>
|
||||
## Whole-Turn folding
|
||||
|
||||
Whole-Turn folding controls the loaded process range, independently of secondary groups. A recorded Turn end makes that range eligible even when paging has not loaded the Turn start.
|
||||
|
||||
The Turn control follows all its opening inputs, including human steering and non-human trigger notices, while waiting for the first Assistant output and after that output arrives. Consecutive inputs before the first process evidence are opening inputs, anchored by the last one. Later inputs retain their positions: even when paging has not loaded their inbox insertions and they temporarily appear as ordinary User messages, preceding process content must not move after them.
|
||||
|
||||
| Admitted input | Chat presentation |
|
||||
|---|---|
|
||||
| Human message claimed from `next-step` | Steering message, outside process folding. |
|
||||
| Non-human message in the current `next-turn` claim | Independent, initially collapsed Turn-trigger notice. |
|
||||
| Non-human message in an idle `next-step` claim | Turn-trigger notice only in Step 1, with a loaded Turn start, a claim after that start, no `next-turn` claim in this Turn, and no human in the same `next-step` batch. |
|
||||
| Other non-human input | Ordinary Context, retained in the Node Store but omitted from Chat. Unclaimed, canceled, or requeued messages do not establish a waking claim. |
|
||||
|
||||
Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs`, and `cordis-host-runner` identify scheduled work, background work, and plugin updates. Goal, agent, team, subagent, and webhook sources have their own titles; a webhook with `provider: github` uses the GitHub title. Unknown sources use the generic execution-request title. Expanding the notice shows its recorded body; it does not imply successful execution.
|
||||
|
||||
| Turn condition | Current behavior |
|
||||
|---|---|
|
||||
| Not closed | Keep process content open; whole-Turn collapse is unavailable. Secondary groups still follow the mode table. |
|
||||
| Stopped or failed | Keep process content open; whole-Turn collapse is unavailable. |
|
||||
| Visible input after the first process evidence | Whole-Turn collapse is unavailable. Preserve process segments, intermediate replies, and input order; group disclosures remain available. Consecutive opening inputs do not qualify. |
|
||||
| Other closed Turn with loaded process content | Initially collapse the process; allow manual opening and closing. Neither the Turn start nor the rest of Session history must already be loaded. |
|
||||
| Closed Turn with no final answer | Collapse all process rows when otherwise eligible. |
|
||||
| End is loaded but start is absent | Apply the closed-Turn rules above to loaded content. Show no elapsed duration; do not use the first loaded tool/message time as the start. |
|
||||
| Neither start nor end is loaded | Keep process rows visible and withhold the whole-Turn control. |
|
||||
| A Turn control exists, but there is no process content | Retain its title, without a collapse action. |
|
||||
|
||||
The final answer is the latest Step's settled Assistant reply, provided it has visible reply content and no tool-call block. Its response remains outside whole-Turn folding; its reasoning remains process content. User and steering inputs, trigger notices, terminal errors, max-token notices, and the completed-turn footer remain independent. Secondary grouping treats model retries as separators, but whole-Turn folding still includes retry rows.
|
||||
|
||||
Loading an older page preserves the reader's group-opening choices. Newly loaded process content follows the same Turn state while the final answer is unchanged and whole-Turn folding remains eligible. If the page reveals an intervening input, individual group disclosures replace whole-Turn hiding. When the real start arrives, the duration becomes available; loading all history is not an additional folding condition. When new content only extends an existing group at its beginning, that group and its old message rows retain their identities and opening choices. Replies, steering, and other real boundaries in the new page still separate groups; not every new row joins the old group.
|
||||
|
||||
Clicking Load older anchors the first visible content item below that button in transcript order, regardless of its position in the viewport. A collapsed group anchors its header; an expanded group skips its header and anchors its first visible member. The whole-Turn process control is excluded because paging can move it ahead of newly loaded work and steering. When a whole Turn is collapsed, the anchor is its first remaining visible message, such as steering or the final answer. Ordinary messages and steering anchor themselves; hidden and empty rows are skipped. Loading more content inside a collapsed group keeps its header stationary; adding earlier groups or rows preserves that same group header while they appear above it.
|
||||
|
||||
Paging adds older content above the retained anchor without jumping to the new top. A capped group absorbs the displacement within its scroll range; the outer transcript absorbs the remainder, including when the group first reaches its cap. If the available scroll range is insufficient, compensation stops at the actual limit without adding bottom space. Later content growth keeps the same anchor until a reading gesture or explicit navigation releases it. Typing or clicking within the composer and non-scrolling transcript keys retain the anchor. If the reader scrolls while a page is loading, scrolling takes priority and its settled reading position becomes the new paging anchor.
|
||||
|
||||
A running clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. Completion fixes the duration; cancellation and failure replace it with their status. Lifecycle changes have a polite announcement; clock ticks do not. This control is Chat's only Turn-level running indicator.
|
||||
|
||||
Automatic collapse keeps the process open if hiding it would hide keyboard focus. Manual closing focuses the process control before hiding its members. Closing a whole Turn resets its groups and inner reasoning/tool disclosures; it does not reset unrelated renderer state. Browser find can reveal searchable hidden content.
|
||||
|
||||
-----
|
||||
|
||||
<a id="display-modes"></a>
|
||||
## Display modes
|
||||
|
||||
Settings → General → Work details stores `ui-chat.transcriptView` as `compact` (default), `detailed`, or `expanded`. A saved legacy `normal` value reads as `detailed` without being written back.
|
||||
|
||||
| Behavior | Compact | Detailed | Expanded |
|
||||
|---|---|---|---|
|
||||
| Process-group header | Category summary | Summary and live task detail | Hidden in running Turns; retained in historical Turns |
|
||||
| Process-group body | Initially collapsed | Initially collapsed | Directly visible without a group-level height cap in running Turns; manual disclosure in historical Turns |
|
||||
| Settled reasoning preview | Hidden | First line | First line |
|
||||
| Individual reasoning and tool bodies | Manual expansion | Manual expansion | Manual expansion |
|
||||
| Eligible completed Turn | Initially collapsed | Initially collapsed | Initially collapsed |
|
||||
|
||||
A closed group's header names the first three categories from its ranked summary, without displaying counts. A group without categories uses the thinking label. A running header names its live tool category, otherwise thinking; Detailed appends live detail. Live titles remain visible for at least 150ms, retaining only the newest pending title.
|
||||
|
||||
### Group-title rules
|
||||
|
||||
The labels below describe recorded activity, not successful outcomes. For example, a failed read still participates in the “Read files” category.
|
||||
|
||||
| Category | Running label | Closed label |
|
||||
|---|---|---|
|
||||
| No live category / no counted categories | Analyzing the request | Analysis completed |
|
||||
| `read` | Reading files | Read files |
|
||||
| `search` | Searching code | Searched code |
|
||||
| `edit` | Editing files | Edited files |
|
||||
| `commands` | Running commands | Ran commands |
|
||||
| `code` | Running code | Ran code |
|
||||
| `webSearch` | Searching the web | Searched the web |
|
||||
| `webFetch` | Visiting web pages | Visited web pages |
|
||||
| `subagents` | Coordinating subagents | Coordinated subagents |
|
||||
| `plan` | Updating the plan | Updated the plan |
|
||||
| `questions` | Waiting for your action | Asked questions |
|
||||
| `tools` | Calling tools | Called tools |
|
||||
|
||||
| Group state | Title composition |
|
||||
|---|---|
|
||||
| Not closed, with a running tool | Use the live category, not the highest-count category. Detailed appends nonempty detail with ` · `; Compact omits detail. |
|
||||
| Not closed, without a running tool | Use the analysis label even when completed-tool counts are nonzero. Detailed may append running reasoning detail. |
|
||||
| Closed, zero categories | Use the completed-analysis label. |
|
||||
| Closed, one category | Use its closed label without a count. |
|
||||
| Closed, two categories | Join ranked labels with “and”. Chinese removes the second leading `已` only when both labels start with it. |
|
||||
| Closed, three categories | Join all three ranked labels with commas. |
|
||||
| Closed, more than three categories | Show the first three labels followed by “etc.” (`等` in Chinese). |
|
||||
|
||||
English lowercases the initial letter of joined labels after the first. Closing a group immediately selects the completed summary; the 150ms minimum applies to running-title changes, not to delaying completion. Expanded hides group headers in running Turns, including groups ended by a reply or steering before their Turn ends.
|
||||
|
||||
Group headers show a category icon, replace it with a down arrow on hover or keyboard focus, and show an up arrow while open. Manually expanded group bodies use 8px row spacing, a `min(400px, 50vh)` height cap, and 24px directional fades. Wheel scrolling can continue into the outer transcript at an edge. Expanded mode removes the group-level cap and uses 16px row spacing only in running Turns.
|
||||
|
||||
Individual reasoning starts collapsed, including while streaming. All modes preview the latest paragraph whose first line ends with a newline; an unfinished single line has no preview. Later text in that paragraph does not change the preview. After settlement, the mode table applies. Expanded reasoning uses compact Markdown typography.
|
||||
|
||||
Switching modes retains manually opened groups and inner disclosures. It changes visibility and sizing without recreating the message rows. Expanded does not mean opening every individual disclosure or unfolding the whole Turn.
|
||||
|
||||
-----
|
||||
|
||||
<a id="process-grouping"></a>
|
||||
## Process grouping
|
||||
|
||||
[process-groups.ts](process-groups.ts) groups visible Chat content; [process-activity.ts](process-activity.ts) summarizes the activity inside each group. Both follow the rules below.
|
||||
|
||||
A group collects adjacent process content within one Turn. Step-number changes alone do not split it. Compact, Detailed, and Expanded use the same grouping result. A group's `closed` flag means its content segment has ended, not that its UI disclosure is collapsed.
|
||||
|
||||
| Input | Membership and segmentation |
|
||||
|---|---|
|
||||
| Non-blank Assistant reasoning | Append one `reasoning` reference for that Assistant to the current group, creating a group if necessary. |
|
||||
| Assistant reply | End the preceding group and emit an independent `response` reference. If the same Node has reasoning, append that reasoning before ending the group. |
|
||||
| `user`, `steering`, `turn-trigger`, `model-retry`, `turn-error`, `turn-max-tokens`, `turn-tail` | End the preceding group and retain the Node as an independent root. |
|
||||
| `turn-process` | Retain the control as an independent root without ending the current group. |
|
||||
| Other visible Nodes owned by a Turn, including tools | Append the whole Node to the current group, creating a group if necessary. |
|
||||
| Node from another Turn or without a Turn | Break the same-Turn sequence. A Node without a Turn remains independent, including an unsplit Assistant. |
|
||||
| Hidden Nodes, system prompts, ordinary Context injection, and `permission` commands | Excluded from the grouping input; they neither join nor split a group. |
|
||||
|
||||
A reply contains non-blank text, an image, or another visible block; reasoning and tool-call protocol blocks do not count as a reply. An Assistant with neither non-blank reasoning nor reply content contributes no reference. Group creation requires a member, so metadata-only Turns create no empty group.
|
||||
|
||||
### Examples
|
||||
|
||||
`A` denotes an Assistant Node, `T` a tool Node, and `G[...]` a process group. Arrows show input or root-reference order; examples share one Turn unless stated otherwise.
|
||||
|
||||
| Input sequence | Grouped roots |
|
||||
|---|---|
|
||||
| `A1(thought, reply) → T1` in one Step | `G[A1.reasoning] → A1.response → G[T1]` |
|
||||
| `T1(step=1) → A2(thought, step=2) → T2` | `G[T1, A2.reasoning, T2]` |
|
||||
| `T1 → steering → T2` | `G[T1] → steering → G[T2]` |
|
||||
| `T1(turn=1) → U(no turn) → T2(turn=1)` | `G[T1] → U → G[T2]` |
|
||||
| `A1(reply)` | `A1.response`, without a process group |
|
||||
| `read → bash → subagent_codex` without an intervening reply or input | One group; changing tool category does not split it. |
|
||||
| `run_code` with nested `read` and `bash` calls | One group member for the `run_code` tool Node; its nested calls do not create separate roots. |
|
||||
| A tool receives a failed result | Update that member without splitting its group; a separate `turn-error` Node does split the sequence. |
|
||||
|
||||
Replies and independent separators close preceding groups immediately, even while the Turn is running. The final group also closes when another Turn or unscoped Node follows it, or when its Turn has status `closed`. Any other or unavailable Turn status alone does not close that trailing group. Regrouping after a separator disappears may reopen it.
|
||||
|
||||
-----
|
||||
|
||||
<a id="activity-summaries"></a>
|
||||
## Activity summaries
|
||||
|
||||
[process-activity.ts](process-activity.ts) derives one group's category counts and live detail from its member Nodes. It does not decide membership or presentation text.
|
||||
|
||||
### Category counts
|
||||
|
||||
Counts describe calls in this group, not successful operations, files changed, child Agents, or elapsed work. The following rules apply before category ranking.
|
||||
|
||||
| Call evidence in this group | Counting rule |
|
||||
|---|---|
|
||||
| Running call, successful result, failed result, or projected interrupted result with its original call | Count one call in the category selected by its recorded tool name. Settlement and failure do not add or subtract a call. |
|
||||
| Repeated `callId` | Count only the first occurrence, including when it appears both as a root and as a nested call. Later occurrences and their subtrees are skipped. |
|
||||
| Parent and child with different `callId` values | Count both, in their own categories; visit the parent before its children and siblings in recorded order. |
|
||||
| Repeated calls to the same tool with different `callId` values | Count each call, even with identical arguments or the same terminal/session target. |
|
||||
| Settled root result whose original call is missing | Do not infer its category from result text or metadata. Count no root call; its recorded subcalls still participate. |
|
||||
| Assistant reasoning, Assistant tool-call protocol blocks, or another Node kind | Add no tool count. The separately projected tool Node owns that call's count. |
|
||||
|
||||
Deduplication is group-local. Counts sort descending; ties retain the category's first occurrence in member order and parent-before-child traversal. For `read → bash → read`, the ranking is `read=2, commands=1`; for `bash → read`, it is `commands=1, read=1`. No category has a fixed priority when counts tie.
|
||||
|
||||
### Tool-name classification
|
||||
|
||||
Classification uses the recorded tool name exactly: no case folding, namespace stripping, provider lookup, or inference from arguments. Names match the first applicable row below. All nonzero categories remain in the summary.
|
||||
|
||||
| Category | Tool-name match |
|
||||
|---|---|
|
||||
| `read` | `read`, `read_image`, `list_mcp_resources`, `list_mcp_resource_templates`, `read_mcp_resource` |
|
||||
| `search` | `grep`, `glob`, or suffix `_inspect` |
|
||||
| `edit` | `write`, `edit`, `apply_patch` |
|
||||
| `commands` | `bash`, `pwsh`, `exec_command`, `write_stdin`, or prefix `terminal_` |
|
||||
| `code` | `run_code` |
|
||||
| `webSearch` | `web_search` |
|
||||
| `webFetch` | `web_fetch` |
|
||||
| `subagents` | `subagent` or prefix `subagent_` |
|
||||
| `plan` | `todo_write`, `create_goal`, `update_goal`, `get_goal` |
|
||||
| `questions` | `ask_user_question`, `request_user_input` |
|
||||
| `tools` | Every other name |
|
||||
|
||||
### Subagents, background jobs, and nested calls
|
||||
|
||||
A subagent tool follows ordinary group boundaries: it does not create a group merely because it delegates work. Its category describes the parent Session's tool call, not the number or lifecycle of child Sessions.
|
||||
|
||||
| Case | Category and activity behavior |
|
||||
|---|---|
|
||||
| `subagent`, `subagent_codex`, `subagent_claude_code`, or another `subagent_*` name | Count one `subagents` call per distinct `callId`, unless an earlier name rule matches. Provider and model choices do not change the category. |
|
||||
| A configured name such as `subagent_inspect` | Classify as `search`: suffix `_inspect` is checked before prefix `subagent_`. The same precedence makes `terminal_inspect` a `search` call rather than `commands`. |
|
||||
| Subagent controls `send_message`, `list_agents`, and `list_subagent_models`; job controls `job_output` and `job_kill` | Classify as `tools`, not `subagents`. Their business purpose does not override the name table. |
|
||||
| A delegation tool renamed to a name outside the table, such as `research_agent` | Classify as `tools`, even if it invokes the same subagent provider. |
|
||||
| Background subagent startup has returned its tool result, while the child is still running | Keep its category count, but remove that call from running-activity candidates. The child does not keep the parent's group in the subagent activity state. |
|
||||
| Tool calls recorded only in a child Session | Do not include them in the parent group's counts or live detail. Only calls actually attached to a member's `subCalls` participate recursively. |
|
||||
|
||||
A `run_code` root counts once as `code`; its recorded PTC subcalls each count in their own categories. The wrapper is not replaced by leaf-call counts, and children are not all classified as `code`. With distinct call ids, `run_code → [read, bash, subagent_codex]` produces `code=1, read=1, commands=1, subagents=1`, in that tie order. A subcall can supply the live category while the outer `run_code` remains running.
|
||||
|
||||
Exact-name rules also mean that a recorded name such as `functions.read`, `mcp.read`, or `Read` falls into `tools`, while `browser_inspect` falls into `search`. A `terminal_*` name follows the command rule even when its operation only inspects or closes a terminal, unless the earlier `_inspect` rule matches.
|
||||
|
||||
### Live activity and detail
|
||||
|
||||
Among running calls, the greatest `time` selects the live category and detail; equal times select the later visited call. With no running call, the category is absent and detail comes from the last nonempty reasoning paragraph of the latest running Assistant with nonempty reasoning, in member order. Reasoning detail removes `**` markers and does not require a newline-terminated first line; the individual reasoning-row preview has separate rules.
|
||||
|
||||
Live selection uses call start `time`, not the latest output/progress time. A call is a running candidate while its projected tool value has no result; pending approval or waiting for a result does not receive a separate category.
|
||||
|
||||
| Members' current state | Live category and detail source |
|
||||
|---|---|
|
||||
| `bash(time=10)` and `read(time=20)` both running | `read`, using the `read` arguments, regardless of which tool most recently emitted output. |
|
||||
| The newer `read` settles while the older `bash` remains running | Return to `commands`, using the `bash` arguments. A failed `read` result has the same selection effect. |
|
||||
| Running parent and child share the same start time | The child wins because it is visited later; later siblings win equal-time ties. |
|
||||
| All tools settled; the group is not closed; a running Assistant has reasoning | No live tool category; use the latest nonempty reasoning paragraph as detail. |
|
||||
| No running tool or usable running reasoning; the group is not closed | No live tool category and empty detail, even when category counts are nonzero. |
|
||||
| A reply or another group boundary closes the group while a tool still lacks a result | Published live category and detail are cleared. Group closure takes precedence over the unfinished call. |
|
||||
|
||||
Tool detail uses the first nonempty supported argument field in this priority order: `title`, `description`, `objective`, `task`, `task_name`, `name`, `question`, `questions`, `prompt`, `message`, `command`, `cmd`, `queries`, `query`, `pattern`, `url`, `uri`, `file_path`, `path`, `target`, `action`, `status`. Strings and arrays consisting entirely of strings are supported; arrays join with `, `. The `questions` field instead selects the first nonempty `question` in its object array. Invalid, partial, or free-form JSON and arguments without usable detail fall back to the tool name.
|
||||
|
||||
The priority list is shared by every tool, not specialized per category. For example, `description` can override `command` on a shell call, and `title` can override a file path. The first field whose supported value remains nonempty after whitespace normalization wins; unsupported values are skipped.
|
||||
|
||||
| Tool and argument example | Resulting detail |
|
||||
|---|---|
|
||||
| `subagent` with `{"description":"Review parser","prompt":"Long instructions"}` | `Review parser`; the prompt is not appended. |
|
||||
| `send_message` with `{"subagent_id":"child-1","message":"Check tests"}` | `Check tests`; its category is still `tools`. |
|
||||
| `exec_command` with `{"cmd":"pnpm test","description":"Run focused tests"}` | `Run focused tests`; without a usable description, `pnpm test`. |
|
||||
| `read` with `{"file_path":"src/app.ts","path":"fallback.ts"}` | `src/app.ts`; `file_path` precedes `path`. |
|
||||
| `grep` with `{"pattern":"TODO","path":"src"}` | `TODO`; the searched path is not appended. |
|
||||
| `web_search` with `{"queries":["React hooks","external store"]}` | `React hooks, external store`. |
|
||||
| `web_search` with only `{"queries":[{"query":"React hooks"}]}` | `web_search`; object-array queries are not extracted recursively. |
|
||||
| `web_fetch` with `{"url":"https://example.com"}`; `read_mcp_resource` with `{"uri":"resource://guide"}` | The URL or URI itself. |
|
||||
| `request_user_input` with `{"questions":[{"header":"Scope","question":"Which package?"},{"question":"Which mode?"}]}` | `Which package?`; ignore headers and later questions once a nonempty question is found. |
|
||||
| `run_code` with only `{"code":"print(1)"}` | `run_code`; `code` is not in the detail-field list. |
|
||||
| `apply_patch` with raw patch text rather than a JSON object | `apply_patch`; no patch-path extraction. |
|
||||
| Any tool with `{"description":" ","command":42,"path":"src/app.ts"}` | `src/app.ts`; whitespace-only strings and numbers are skipped. |
|
||||
| Partial JSON, a JSON scalar, a mixed-type array, or only unsupported fields | Use the tool name if no other usable field exists. No partial-argument guessing. |
|
||||
|
||||
All live detail collapses whitespace, trims its ends, and is limited to 160 grapheme clusters, including a final `…` when truncated. A closed group's published summary clears its live category and detail while retaining category counts.
|
||||
@@ -0,0 +1,290 @@
|
||||
---
|
||||
description: "Chat 过程分组与活动摘要的明细业务规则。"
|
||||
---
|
||||
# Chat 节点业务规则
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
## 概述
|
||||
|
||||
一轮对话既要展示答案,也要让用户按需查看产生答案的过程。先通过下面的完整例子理解画面,再看整轮折叠和展示模式决定什么可见;后面的明细逐项定义分组边界、所有工具名称类别、计数及实时详情选择。
|
||||
|
||||
## 目录
|
||||
|
||||
- [先理解一轮对话怎样显示](#reading-model)
|
||||
- [整轮折叠](#whole-turn-folding)
|
||||
- [展示模式](#display-modes)
|
||||
- [过程分组](#process-grouping)
|
||||
- [活动摘要与工具明细](#activity-summaries)
|
||||
|
||||
-----
|
||||
|
||||
<a id="reading-model"></a>
|
||||
## 先理解一轮对话怎样显示
|
||||
|
||||
分组、类别计数、展示模式和整轮折叠分别回答不同问题:分组决定哪些过程行放在一起;计数概括一个已有组里做了什么;展示模式决定组内显示多少;没有中途输入打断时,整轮折叠可以一次隐藏跨越多个组和阶段回复的全部过程。
|
||||
|
||||
### 一个完整例子
|
||||
|
||||
假设助手先思考、读文件、运行命令,给出一段进展回复,然后继续思考、执行代码,最后给出答案。该轮完成后,内容在业务上分成:
|
||||
|
||||
- 用户输入。
|
||||
- 整轮控件。
|
||||
- 由整轮控件控制的过程范围:
|
||||
- 过程组 G1:思考、`read`、`bash`。
|
||||
- 阶段回复:不属于 G1 或 G2,但仍属于整轮过程。
|
||||
- 过程组 G2:思考、`run_code`。
|
||||
- 最终答案正文,不随过程收起。
|
||||
- 已完成轮次页脚。
|
||||
|
||||
每次回复都会截断二级组,但只有最终答案正文不参与整轮折叠。因此,阶段回复虽然是一行独立内容,整轮收起时仍会消失。最终答案附带的思考仍属于过程内容。
|
||||
|
||||
| 问题 | 由什么决定 | 例子中的结果 |
|
||||
|---|---|---|
|
||||
| 哪些行放在一起? | 分组边界 | 阶段回复分开 G1 与 G2;从 `read` 换成 `bash` 不会拆组。 |
|
||||
| 一组做了什么? | 类别计数 | G1 为 `read=1, commands=1`,G2 为 `code=1`;思考不增加工具次数。 |
|
||||
| 组标题写什么? | 组状态与摘要 | 未结束组显示最近运行活动;已结束组显示次数最多的类别。次数不决定折叠。 |
|
||||
| 组内显示多少细节? | 展示模式与手动开合 | 已完成轮次各模式都保留组头,正文手动展开;Expanded 仅对运行中轮次直接显示组正文。 |
|
||||
| 过程整体是否可见? | 整轮开合 | 整轮收起时,G1、阶段回复和 G2 一起隐藏。 |
|
||||
|
||||
### 用户实际看到什么
|
||||
|
||||
以下假设上面的轮次正常结束,用户尚未手动打开内部控件:
|
||||
|
||||
| 查看状态 | 可见内容 |
|
||||
|---|---|
|
||||
| 整轮收起,任意模式 | 输入、整轮控件、最终答案和页脚;过程组头、正文及阶段回复都不显示。 |
|
||||
| 整轮展开,Compact 或 Detailed | G1/G2 组头、阶段回复及最终答案;组正文初始收起。 |
|
||||
| 整轮和 G1 展开,Detailed | G1 的思考与工具行,思考带已结算预览;G2 仍是组头。单个完整正文仍需手动打开。 |
|
||||
| 整轮展开,Expanded | G1/G2 组头、阶段回复及最终答案;已完成轮次的组正文仍需手动打开。 |
|
||||
|
||||
显隐从外向内生效:整轮 → 二级组 → 单个思考或工具。内层展开不能越过外层收起。类别变化不会开合任何一层;切换模式保留分组和用户的手动展开选择。
|
||||
|
||||
若过程开始后又出现 steering、User 或触发通知,该轮不提供整轮收起,只保留各组自己的开合。`G1 → steering 1 → G2 → steering 2 → G3` 在 Compact/Detailed 中始终保留三个组头及两条输入的顺序,阶段回复也保持可见;打开一个组只显示该组正文。Expanded 仅在该轮仍运行时直接显示组正文,完成后保留组头及手动开合。整轮控件仍显示时长或状态,但不提供折叠操作。
|
||||
|
||||
-----
|
||||
|
||||
<a id="whole-turn-folding"></a>
|
||||
## 整轮折叠
|
||||
|
||||
整轮折叠控制已加载的过程范围,独立于二级分组。已经记录轮次结束时,即使向前分页尚未加载到轮次起点,也可以对该范围折叠。
|
||||
|
||||
整轮控件位于该轮所有起始输入之后,包括人工 steering 和非人工触发通知;等待首个 Assistant 输出及输出到达后均保持这一顺序。首个过程证据之前的连续输入都属于开场,以最后一条为准;过程证据之后的输入留在原位,即使分页尚未加载其入队记录、消息暂时归为普通 User,也不能把前面的过程移到它后面。
|
||||
|
||||
| 已进入请求的输入 | Chat 展示 |
|
||||
|---|---|
|
||||
| 从 `next-step` 领取的人工消息 | Steering 消息,不参与过程折叠。 |
|
||||
| 当前 `next-turn` 领取批次中的非人工消息 | 独立、默认收起的轮次触发通知。 |
|
||||
| 空闲时从 `next-step` 领取的非人工消息 | 仅在 Step 1、已加载轮次起点、领取发生于起点之后、本轮没有 `next-turn` 领取、同一 `next-step` 批次没有人工消息时,作为轮次触发通知。 |
|
||||
| 其他非人工输入 | 普通 Context,保留在 Node Store,但不显示在 Chat 中。未领取、已取消或重新入队的消息不构成唤醒领取。 |
|
||||
|
||||
触发通知的标题与图标使用记录中的 `source.kind`:`schedule`、`tool-jobs`、`cordis-host-runner` 分别表示定时任务、后台任务和插件更新。目标、Agent、团队、子任务和 webhook 来源有各自的标题;`provider: github` 的 webhook 使用 GitHub 标题。未知来源显示通用执行请求标题。展开通知显示记录的正文,不代表执行成功。
|
||||
|
||||
| 轮次条件 | 当前行为 |
|
||||
|---|---|
|
||||
| 尚未结束 | 过程内容保持展开,不能收起整轮;二级组仍按模式表展示。 |
|
||||
| 已停止或失败 | 过程内容保持展开,不能收起整轮。 |
|
||||
| 首个过程证据之后还有可见输入 | 不能收起整轮;保留各段过程、阶段回复及输入的顺序,组级开合仍可用。开场连续输入不属于此情况。 |
|
||||
| 其他已结束轮次,有已加载的过程内容 | 过程默认收起,允许手动开合;不要求已加载到轮次起点,也不要求整个 Session 历史都已加载。 |
|
||||
| 已结束但没有最终答案 | 满足其他折叠条件时收起全部过程行。 |
|
||||
| 已加载结束记录,但缺少起点 | 对已加载内容应用上述已结束轮次规则;不显示耗时,不用最早加载的工具或消息时间冒充起点。 |
|
||||
| 起点与结束记录均未加载 | 保留过程行可见,不显示整轮控件。 |
|
||||
| 存在整轮控件,但没有过程内容 | 保留标题,不提供折叠操作。 |
|
||||
|
||||
最终答案取最后一个 Step 中已结算的 Assistant 回复,要求有可见回复内容且没有工具调用块。最终答案的回复正文不参与整轮折叠,推理仍属于过程。用户与 steering 输入、触发通知、终止错误、最大 token 提示及已完成轮次页脚保持独立。二级分组把模型重试视为分隔节点,但整轮折叠仍包含重试行。
|
||||
|
||||
加载更早的一页保留用户的组级开合选择。最终答案未变化且仍符合整轮折叠条件时,新加载的过程内容沿用该轮的开合状态;若新页揭示中途输入,则改用各组开合,不再整轮隐藏。真正的起点到达后才显示时长,是否已加载完全部历史不是额外折叠条件。若新内容只是接在旧组前面,旧组和旧消息行保留身份及已有展开状态。新页出现回复、steering 等真实边界时仍按规则分组,不把所有新增内容强并进旧组。
|
||||
|
||||
点击加载更早时,按正文顺序锚定按钮下的第一个可见内容项,不根据它在视口中的位置选择。组收起时选组 header;展开时跳过组 header,选内部第一个可见成员。整轮过程控件不作为锚点,因为分页可能将它移到新加载的过程及 steering 前面。整轮收起时,锚定第一个仍可见的消息,例如 steering 或最终答案。普通消息与 steering 选择自身,跳过隐藏和空行。新增内容全进入收起的组时,组 header 保持原位;前面出现更早的组或节点时,仍保持该组 header 的位置,让新增内容出现在它上方。
|
||||
|
||||
分页把更早内容加到保留的锚点上方,不主动跳到新内容顶部。限高组先在自己的滚动范围内吸收位移,剩余部分由外层文本滚动补偿,包括组正文刚达到限高的情况。可滚动范围不足时,补偿停在实际边界,不增加底部空白。后续内容撑高沿用同一个锚点,直到阅读手势或显式导航释放它。在 composer 中打字或点击,以及 transcript 内的非滚动按键,保留锚点。分页请求期间若读者滚动,优先服从滚动,停止后以新的阅读位置作为分页锚点。
|
||||
|
||||
运行时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。完成后固定时长,取消与失败使用对应状态替代。生命周期变化有礼貌播报,时钟跳动不播报。该控件是 Chat 唯一的轮次级运行指示。
|
||||
|
||||
自动收起若会隐藏键盘焦点,则保持过程展开。手动收起先将焦点移到过程控件,再隐藏成员。收起整轮会重置内部组及推理、工具的开合,不重置渲染器的其他状态。浏览器查找可以显示可搜索的隐藏内容。
|
||||
|
||||
-----
|
||||
|
||||
<a id="display-modes"></a>
|
||||
## 展示模式
|
||||
|
||||
设置 → 通用设置 → 工作过程展示将 `ui-chat.transcriptView` 保存为 `compact`(默认)、`detailed` 或 `expanded`。已保存的旧值 `normal` 读取为 `detailed`,不自动回写。
|
||||
|
||||
| 行为 | 简洁 | 详细 | 完全展开 |
|
||||
|---|---|---|---|
|
||||
| 过程组头 | 类别摘要 | 摘要与实时任务详情 | 运行中轮次隐藏;历史轮次保留 |
|
||||
| 过程组正文 | 初始收起 | 初始收起 | 运行中轮次直接显示、不设组级限高;历史轮次按手动开合显示 |
|
||||
| 已结算推理预览 | 隐藏 | 首行 | 首行 |
|
||||
| 单个推理与工具正文 | 手动展开 | 手动展开 | 手动展开 |
|
||||
| 符合条件的已完成轮次 | 初始收起 | 初始收起 | 初始收起 |
|
||||
|
||||
已结束组的组头展示排序摘要的前三类,不显示次数;没有类别时使用思考标题。运行中的组头展示实时工具类别,没有运行工具时使用思考标题;详细模式附加实时详情。实时标题至少保留 150ms,只保留最新待显示标题。
|
||||
|
||||
### 组标题规则
|
||||
|
||||
以下文案描述已记录的活动,不保证操作成功;例如失败的读取仍参与「已读取文件」类别。
|
||||
|
||||
| 类别 | 未结束组的活动文案 | 已结束组的摘要文案 |
|
||||
|---|---|---|
|
||||
| 无实时类别 / 无计数类别 | 正在分析请求 | 已完成分析 |
|
||||
| `read` | 正在读取文件 | 已读取文件 |
|
||||
| `search` | 正在搜索代码 | 已搜索代码 |
|
||||
| `edit` | 正在编辑文件 | 修改了文件 |
|
||||
| `commands` | 正在运行命令 | 执行了命令 |
|
||||
| `code` | 正在运行代码 | 运行了代码 |
|
||||
| `webSearch` | 正在搜索网页 | 已搜索网页 |
|
||||
| `webFetch` | 正在访问网页 | 已访问网页 |
|
||||
| `subagents` | 正在协调子任务 | 已协调子任务 |
|
||||
| `plan` | 正在更新计划 | 更新了计划 |
|
||||
| `questions` | 等待你的操作 | 向用户提出了问题 |
|
||||
| `tools` | 正在调用工具 | 已调用工具 |
|
||||
|
||||
| 组状态 | 标题组合规则 |
|
||||
|---|---|
|
||||
| 未结束,有运行工具 | 取实时类别,不取计数最多的类别。Detailed 用 ` · ` 追加非空详情,Compact 不追加。 |
|
||||
| 未结束,没有运行工具 | 即使已完成工具次数非零,也使用分析文案;Detailed 可以追加运行中推理详情。 |
|
||||
| 已结束,零个类别 | 使用「已完成分析」。 |
|
||||
| 已结束,一个类别 | 使用该类别的已结束文案,不带次数。 |
|
||||
| 已结束,两个类别 | 按排名用「并」连接;仅当两项都以 `已` 开头时省略第二项的 `已`,例如 `已读取文件并搜索代码`。 |
|
||||
| 已结束,三个类别 | 按排名用中文逗号 `,` 连接三项文案。 |
|
||||
| 已结束,超过三个类别 | 只显示前三项文案,末尾加「等」。 |
|
||||
|
||||
英文组合标题将第一项之后各项的首字母转为小写。组结束时立即切换完成摘要;150ms 最短保留时间针对运行中标题变化,不延迟完成状态。Expanded 隐藏运行中轮次的组头,包括被回复或 steering 截断、但所属轮次尚未结束的小组。
|
||||
|
||||
组头显示类别图标,悬停或键盘聚焦时替换为向下箭头,展开时显示向上箭头。手动展开的组正文使用 8px 行间距、`min(400px, 50vh)` 限高及 24px 方向渐隐;到达边缘时滚轮可以继续滚动外层文本记录。完全展开模式仅对运行中轮次取消组级限高,使用 16px 行间距。
|
||||
|
||||
单个推理初始收起,包括流式输出期间。三档都预览最近一个首行以换行结束的段落;未完成的单行没有预览,该段落后续文本不改变预览。结算后按上表展示。展开的推理使用紧凑 Markdown 排版。
|
||||
|
||||
切换模式保留手动展开的组和内部开合状态,只改变显隐与尺寸,不重建消息行。完全展开不表示自动打开每个内部控件,也不表示展开整轮。
|
||||
|
||||
-----
|
||||
|
||||
<a id="process-grouping"></a>
|
||||
## 过程分组
|
||||
|
||||
[process-groups.ts](process-groups.ts) 划分可见 Chat 内容,[process-activity.ts](process-activity.ts) 汇总每组活动,两者共同遵循下列业务规则。
|
||||
|
||||
一组收集同一轮次内相邻的过程内容,仅 Step 编号变化不会拆组。Compact、Detailed 和 Expanded 使用同一分组结果。组的 `closed` 表示这段内容已经结束,不表示 UI 开合控件处于收起状态。
|
||||
|
||||
| 输入 | 归属与切分 |
|
||||
|---|---|
|
||||
| 非空白 Assistant 推理 | 将该 Assistant 的一个 `reasoning` 引用加入当前组,必要时创建组。 |
|
||||
| Assistant 回复 | 结束前面的组,输出独立的 `response` 引用。同一 Node 含有推理时,先将推理加入组,再结束该组。 |
|
||||
| `user`、`steering`、`turn-trigger`、`model-retry`、`turn-error`、`turn-max-tokens`、`turn-tail` | 结束前面的组,Node 作为独立根引用保留。 |
|
||||
| `turn-process` | 控件作为独立根引用保留,不结束当前组。 |
|
||||
| 轮次内其他可见 Node,包括工具 | 将整个 Node 加入当前组,必要时创建组。 |
|
||||
| 另一轮次或没有轮次归属的 Node | 截断同轮次序列。无轮次 Node 保持独立,Assistant 也不拆分。 |
|
||||
| 隐藏 Node、系统提示词、普通 Context 注入及 `permission` 命令 | 不进入分组输入,既不入组,也不截断组。 |
|
||||
|
||||
回复须包含非空白文本、图片或其他可见块;推理和工具调用协议块不算回复。既无非空白推理、也无回复内容的 Assistant 不产生引用。只有存在成员才创建组,因此仅含元数据的轮次不会产生空组。
|
||||
|
||||
### 示例
|
||||
|
||||
`A` 表示 Assistant Node,`T` 表示工具 Node,`G[...]` 表示过程组。箭头表示输入或根引用顺序;未注明时,示例均属于同一轮次。
|
||||
|
||||
| 输入序列 | 分组后的根引用 |
|
||||
|---|---|
|
||||
| 同一 Step 的 `A1(thought, reply) → T1` | `G[A1.reasoning] → A1.response → G[T1]` |
|
||||
| `T1(step=1) → A2(thought, step=2) → T2` | `G[T1, A2.reasoning, T2]` |
|
||||
| `T1 → steering → T2` | `G[T1] → steering → G[T2]` |
|
||||
| `T1(turn=1) → U(no turn) → T2(turn=1)` | `G[T1] → U → G[T2]` |
|
||||
| `A1(reply)` | `A1.response`,不产生过程组 |
|
||||
| `read → bash → subagent_codex`,中间没有回复或输入 | 同一组;工具类别变化不会拆组。 |
|
||||
| `run_code` 内含 `read`、`bash` 子调用 | `run_code` 工具 Node 是一个组成员,其嵌套调用不产生独立根引用。 |
|
||||
| 工具收到失败结果 | 更新该成员,不因此拆组;另外产生的 `turn-error` Node 才会截断序列。 |
|
||||
|
||||
即使轮次仍在运行,回复和独立分隔节点也会立即结束前面的组。尾组在后面出现另一轮次或无轮次 Node 时结束,所属轮次状态为 `closed` 时也结束。其他轮次状态或无法获知轮次状态,本身不会结束尾组。分隔节点消失后重新分组,原组可以再次变为未结束。
|
||||
|
||||
-----
|
||||
|
||||
<a id="activity-summaries"></a>
|
||||
## 活动摘要
|
||||
|
||||
[process-activity.ts](process-activity.ts) 从一组的成员 Node 计算类别次数和实时详情,不决定成员归属或展示文案。
|
||||
|
||||
### 类别计数
|
||||
|
||||
这里统计本组的工具调用,不是成功操作数、修改文件数、子 Agent 数量或工作时长。先按下表确定计数,再按类别排序。
|
||||
|
||||
| 本组中的调用记录 | 计数规则 |
|
||||
|---|---|
|
||||
| 运行中调用、成功结果、失败结果,或保留原调用信息的中断结果 | 按记录的工具名计入对应类别一次;结算或失败不会增加或扣除次数。 |
|
||||
| 重复的 `callId` | 只计首次出现,包括同时作为根调用和嵌套调用出现的情况;后续重复记录及其子树不再处理。 |
|
||||
| 父子调用的 `callId` 不同 | 父子分别按各自类别计数;父调用先于子调用,同级按记录顺序处理。 |
|
||||
| 同一工具使用不同 `callId` 多次调用 | 每次都计数,即使参数相同,或操作的是同一个终端、Session。 |
|
||||
| 已结算根结果缺少原调用记录 | 不从结果文字或 metadata 猜测类别;根调用不计数,已记录的子调用仍参与统计。 |
|
||||
| Assistant 推理、Assistant 内的工具调用协议块,或其他 Node 类型 | 不增加工具次数;调用由独立投影出的工具 Node 负责计数。 |
|
||||
|
||||
去重范围是单个组。类别按次数降序排列,同数时保留该类别在成员顺序及父先子后遍历中的首次出现顺序。`read → bash → read` 排为 `read=2, commands=1`;`bash → read` 排为 `commands=1, read=1`。次数相同时没有固定的类别优先级。
|
||||
|
||||
### 工具名分类
|
||||
|
||||
分类只使用记录的工具名:不转换大小写、不剥离命名空间、不查 provider,也不根据参数推断。名称按下表从上到下首次命中的规则分类,摘要保留全部非零类别。
|
||||
|
||||
| 类别 | 工具名匹配 |
|
||||
|---|---|
|
||||
| `read` | `read`、`read_image`、`list_mcp_resources`、`list_mcp_resource_templates`、`read_mcp_resource` |
|
||||
| `search` | `grep`、`glob`,或以 `_inspect` 结尾 |
|
||||
| `edit` | `write`、`edit`、`apply_patch` |
|
||||
| `commands` | `bash`、`pwsh`、`exec_command`、`write_stdin`,或以 `terminal_` 开头 |
|
||||
| `code` | `run_code` |
|
||||
| `webSearch` | `web_search` |
|
||||
| `webFetch` | `web_fetch` |
|
||||
| `subagents` | `subagent`,或以 `subagent_` 开头 |
|
||||
| `plan` | `todo_write`、`create_goal`、`update_goal`、`get_goal` |
|
||||
| `questions` | `ask_user_question`、`request_user_input` |
|
||||
| `tools` | 其他所有名称 |
|
||||
|
||||
### Subagent、后台任务与嵌套调用
|
||||
|
||||
Subagent 工具遵循普通分组边界,不会仅因委派任务就单独成组。它的类别描述父 Session 中的工具调用,不表示子 Session 的数量或生命周期。
|
||||
|
||||
| 情况 | 类别与活动规则 |
|
||||
|---|---|
|
||||
| `subagent`、`subagent_codex`、`subagent_claude_code` 或其他 `subagent_*` 名称 | 每个不同 `callId` 计一次 `subagents`,但先命中更靠前的名称规则时除外;provider 和 model 选择不改变类别。 |
|
||||
| 配置为 `subagent_inspect` 这样的名称 | 归 `search`:`_inspect` 后缀先于 `subagent_` 前缀匹配;同理,`terminal_inspect` 归 `search` 而不是 `commands`。 |
|
||||
| 子代理控制工具 `send_message`、`list_agents`、`list_subagent_models`,以及任务控制工具 `job_output`、`job_kill` | 归 `tools`,不归 `subagents`;业务用途不覆盖名称表。 |
|
||||
| 委派工具被改为表外名称,例如 `research_agent` | 即使调用相同的 subagent provider,也归 `tools`。 |
|
||||
| 后台 subagent 的启动调用已返回工具结果,但子代理仍在运行 | 保留类别次数,但该调用不再参与运行中活动选择;子代理的执行不会维持父组的 subagent 活动状态。 |
|
||||
| 工具调用只记录在子 Session 中 | 不纳入父组计数或实时详情;只有实际附在本组成员 `subCalls` 中的调用才递归参与。 |
|
||||
|
||||
`run_code` 根调用自身计一次 `code`,已记录的每个 PTC 子调用另按自己的类别计数。不会用叶子调用替代包装调用,也不会把子调用统一归为 `code`。各调用 id 不同时,`run_code → [read, bash, subagent_codex]` 得到 `code=1, read=1, commands=1, subagents=1`,同次数时按此顺序排列。外层 `run_code` 仍在运行时,组的实时类别可以由子调用决定。
|
||||
|
||||
精确名称规则也意味着:记录为 `functions.read`、`mcp.read` 或 `Read` 的名称归 `tools`,`browser_inspect` 归 `search`。`terminal_*` 即使只检查或关闭终端,也按命令规则分类,除非先命中 `_inspect` 规则。
|
||||
|
||||
### 实时活动与详情
|
||||
|
||||
运行中的调用以最大 `time` 决定实时类别和详情;时间相同时取后遍历的调用。没有运行调用时,类别为空,详情取成员顺序中最近一个含有非空推理段落的运行中 Assistant 的最后一个非空段落。推理详情移除 `**` 标记,不要求首行以换行结束;单个推理行的预览另有规则。
|
||||
|
||||
实时选择依据调用开始的 `time`,不是最近一次输出或进度的时间。投影后的工具值尚无结果时就参与运行中候选;等待批准或等待结果不会产生另一个类别。
|
||||
|
||||
| 成员当前状态 | 实时类别与详情来源 |
|
||||
|---|---|
|
||||
| `bash(time=10)` 与 `read(time=20)` 都运行中 | 取 `read` 及其参数,与哪个工具最近输出无关。 |
|
||||
| 较新的 `read` 已结算,较早的 `bash` 仍在运行 | 回到 `commands`,使用 `bash` 参数;`read` 失败后结算也一样。 |
|
||||
| 运行中的父子调用开始时间相同 | 子调用因后遍历而胜出;同级同时间则后遍历者胜出。 |
|
||||
| 工具全部结算,组尚未结束,运行中的 Assistant 有推理 | 实时工具类别为空,详情取最近的非空推理段落。 |
|
||||
| 没有运行工具或可用的运行中推理,组尚未结束 | 实时工具类别为空、详情为空,即使类别次数非零。 |
|
||||
| 工具尚无结果,但回复或其他分组边界已结束该组 | 发布的实时类别与详情清空;组结束优先于调用未完成。 |
|
||||
|
||||
工具详情按以下优先级取第一个非空且受支持的参数字段:`title`、`description`、`objective`、`task`、`task_name`、`name`、`question`、`questions`、`prompt`、`message`、`command`、`cmd`、`queries`、`query`、`pattern`、`url`、`uri`、`file_path`、`path`、`target`、`action`、`status`。支持字符串及全部元素均为字符串的数组,数组使用 `, ` 连接。`questions` 字段则取对象数组中首个非空的 `question`。无效、不完整或自由文本形式的 JSON,以及没有可用详情的参数,均回退到工具名。
|
||||
|
||||
这份优先级对所有工具相同,不按类别定制。例如 shell 调用的 `description` 可以覆盖 `command`,`title` 可以覆盖文件路径。按顺序选择受支持且合并空白后仍非空的首个字段,不支持的值会跳过。
|
||||
|
||||
| 工具与参数示例 | 最终详情 |
|
||||
|---|---|
|
||||
| `subagent`,`{"description":"Review parser","prompt":"Long instructions"}` | `Review parser`,不追加 prompt。 |
|
||||
| `send_message`,`{"subagent_id":"child-1","message":"Check tests"}` | `Check tests`,类别仍是 `tools`。 |
|
||||
| `exec_command`,`{"cmd":"pnpm test","description":"Run focused tests"}` | `Run focused tests`;没有可用 description 时才是 `pnpm test`。 |
|
||||
| `read`,`{"file_path":"src/app.ts","path":"fallback.ts"}` | `src/app.ts`,`file_path` 先于 `path`。 |
|
||||
| `grep`,`{"pattern":"TODO","path":"src"}` | `TODO`,不追加搜索路径。 |
|
||||
| `web_search`,`{"queries":["React hooks","external store"]}` | `React hooks, external store`。 |
|
||||
| `web_search`,仅有 `{"queries":[{"query":"React hooks"}]}` | `web_search`,不递归提取对象数组中的查询。 |
|
||||
| `web_fetch` 的 `{"url":"https://example.com"}`;`read_mcp_resource` 的 `{"uri":"resource://guide"}` | 直接使用 URL 或 URI。 |
|
||||
| `request_user_input`,`{"questions":[{"header":"Scope","question":"Which package?"},{"question":"Which mode?"}]}` | `Which package?`;找到首个非空问题后,不取 header 或后续问题。 |
|
||||
| `run_code`,仅有 `{"code":"print(1)"}` | `run_code`,`code` 不在详情字段列表中。 |
|
||||
| `apply_patch`,参数为原始补丁文本而非 JSON 对象 | `apply_patch`,不从补丁中提取路径。 |
|
||||
| 任意工具,`{"description":" ","command":42,"path":"src/app.ts"}` | `src/app.ts`,跳过纯空白字符串和数值。 |
|
||||
| 不完整 JSON、JSON 标量、混合类型数组,或仅有不支持的字段 | 没有其他可用字段时使用工具名,不猜测不完整参数。 |
|
||||
|
||||
所有实时详情都合并空白、去除首尾空白,并限制为 160 个字素簇;截断时末尾的 `…` 计入上限。已结束组发布的摘要清空实时类别和详情,保留类别次数。
|
||||
@@ -34,7 +34,6 @@ interface AssistantState {
|
||||
readonly firstVisibleSeq: number | undefined
|
||||
readonly firstVisibleTime: number | undefined
|
||||
readonly firstTokenTime: number | undefined
|
||||
readonly hidden: boolean
|
||||
readonly final: ConversationMatch | undefined
|
||||
readonly usage: unknown
|
||||
}
|
||||
@@ -48,7 +47,6 @@ function initialState(turn: number, step: number): AssistantState {
|
||||
firstVisibleSeq: undefined,
|
||||
firstVisibleTime: undefined,
|
||||
firstTokenTime: undefined,
|
||||
hidden: false,
|
||||
final: undefined,
|
||||
usage: undefined,
|
||||
}
|
||||
@@ -85,7 +83,6 @@ function resetForRetry(state: AssistantState): AssistantState {
|
||||
return {
|
||||
...initialState(state.turn, state.step),
|
||||
firstTokenTime: state.firstTokenTime,
|
||||
hidden: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +148,6 @@ function updateChunk(
|
||||
...state,
|
||||
blocks,
|
||||
visibleBlocks,
|
||||
hidden: visibleBlocks > 0 ? false : state.hidden,
|
||||
...visibleBlocks > 0 && state.firstVisibleSeq === undefined
|
||||
? { firstVisibleSeq: seq, firstVisibleTime: time }
|
||||
: {},
|
||||
@@ -171,7 +167,6 @@ function settleMessage(
|
||||
...state,
|
||||
blocks,
|
||||
visibleBlocks: countVisibleBlocks(blocks),
|
||||
hidden: false,
|
||||
final: match,
|
||||
usage: event.data.usage,
|
||||
}
|
||||
@@ -289,7 +284,7 @@ function publishedAssistantData(
|
||||
return location?.kind === 'step' ? location.step.data.get('assistant-step') : undefined
|
||||
}
|
||||
|
||||
/** Per-step Assistant streaming/final/interruption Definition. */
|
||||
/** Per-step Assistant lifecycle; materialized keys survive cleared stream content as hidden Nodes. */
|
||||
export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
|
||||
kind: 'assistant-step',
|
||||
target: 'chat',
|
||||
@@ -337,16 +332,15 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
|
||||
}
|
||||
},
|
||||
buildViewNode: (context) => {
|
||||
const current = context.current.get('chat')
|
||||
const state = context.state ?? fallbackState(context)
|
||||
if (state === undefined) return null
|
||||
const data = publishedAssistantData(context)
|
||||
if (data === undefined) return null
|
||||
if (state === undefined || data === undefined) {
|
||||
return current == null ? null : { ...current, visibility: 'hidden' }
|
||||
}
|
||||
const settled = data.finalNode
|
||||
const visible = settled === undefined ? state.visibleBlocks > 0 : hasVisibleContent(data.blocks)
|
||||
if (settled === undefined && !visible) {
|
||||
const current = context.current.get('chat')
|
||||
if (!state.hidden || current === undefined || current === null) return null
|
||||
}
|
||||
if (settled === undefined && !visible && current == null) return null
|
||||
const anchorSeq = settled?.seq ?? state.firstVisibleSeq ?? context.matches[0]?.event.seq ?? 0
|
||||
return chatNode(context, 'assistant-step', anchorSeq, data, {
|
||||
visibility: settled?.interrupted === true || visible ? 'visible' : 'hidden',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
|
||||
import { notifySubscribers, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type {
|
||||
ConversationLocation, ConversationNode, ConversationTimelineSnapshot, ConversationViewBuilder,
|
||||
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 type { ChatConversationViewNode, ChatNode, ChatNodeDataMap, ChatNodeKind } from '../contract/chat-nodes.ts'
|
||||
import { isRunningTool } from '../contract/chat-nodes.ts'
|
||||
import { isVisibleChatNode } from '../contract/chat-visibility.ts'
|
||||
import type {
|
||||
@@ -66,6 +66,42 @@ class MutableChatSource<Value> {
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/** Membership is indexed on write; ordered arrays are materialized only for observed collections. */
|
||||
class TurnKindNodes {
|
||||
private readonly nodes = new Map<string, ChatConversationViewNode>()
|
||||
private current: readonly unknown[] = EMPTY_LIST
|
||||
private dirty = false
|
||||
private observable: MutableChatSource<readonly unknown[]> | undefined
|
||||
|
||||
source(): ObservableSnapshot<readonly unknown[]> {
|
||||
return this.observable ??= new MutableChatSource(() => this.read(), '[ui-chat] turn kind nodes')
|
||||
}
|
||||
|
||||
set(node: ChatConversationViewNode): void {
|
||||
const previous = this.nodes.get(node.key)
|
||||
this.nodes.set(node.key, node)
|
||||
if (previous !== undefined && previous.data === node.data && previous.anchorSeq === node.anchorSeq) return
|
||||
this.dirty = true
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.nodes.delete(key)
|
||||
this.dirty = true
|
||||
}
|
||||
|
||||
publish(): void {
|
||||
this.observable?.publish()
|
||||
}
|
||||
|
||||
private read(): readonly unknown[] {
|
||||
if (this.dirty) {
|
||||
this.current = [...this.nodes.values()].sort((a, b) => a.anchorSeq - b.anchorSeq).map(node => node.data)
|
||||
this.dirty = false
|
||||
}
|
||||
return this.current
|
||||
}
|
||||
}
|
||||
|
||||
class MutableChatNodeStore implements ChatNodeStore {
|
||||
private readonly byKey = new Map<string, ChatConversationViewNode>()
|
||||
private readonly turnProcesses = new ChatTurnProcessProjector()
|
||||
@@ -73,6 +109,8 @@ class MutableChatNodeStore implements ChatNodeStore {
|
||||
private readonly processSources = new Map<string, MutableChatSource<ChatTurnProcessPresentation | undefined>>()
|
||||
private readonly dirtyKeys = new Set<string>()
|
||||
private readonly dirtyProcessKeys = new Set<string>()
|
||||
private readonly turnKinds = new Map<number, Map<string, TurnKindNodes>>()
|
||||
private readonly dirtyTurnKinds = new Set<TurnKindNodes>()
|
||||
private valuesCache: readonly ChatConversationViewNode[] = EMPTY_LIST
|
||||
private valuesDirty = false
|
||||
|
||||
@@ -87,6 +125,30 @@ class MutableChatNodeStore implements ChatNodeStore {
|
||||
))
|
||||
}
|
||||
|
||||
turnDataSource<Kind extends ChatNodeKind>(turn: number, kind: Kind): ObservableSnapshot<readonly ChatNodeDataMap[Kind][]> {
|
||||
return this.turnKind(turn, kind).source() as ObservableSnapshot<readonly ChatNodeDataMap[Kind][]>
|
||||
}
|
||||
|
||||
private turnKind(turn: number, kind: string): TurnKindNodes {
|
||||
const kinds = cachedSource(this.turnKinds, turn, () => new Map<string, TurnKindNodes>())
|
||||
return cachedSource(kinds, kind, () => new TurnKindNodes())
|
||||
}
|
||||
|
||||
private updateTurnKind(previous: ChatConversationViewNode | undefined, next: ChatConversationViewNode | undefined): void {
|
||||
const before = previous === undefined ? undefined : locationCoordinates(previous.location).turn
|
||||
const after = next === undefined ? undefined : locationCoordinates(next.location).turn
|
||||
if (previous !== undefined && before !== undefined && (before !== after || previous.kind !== next?.kind)) {
|
||||
const collection = this.turnKind(before, previous.kind)
|
||||
collection.delete(previous.key)
|
||||
this.dirtyTurnKinds.add(collection)
|
||||
}
|
||||
if (next !== undefined && after !== undefined) {
|
||||
const collection = this.turnKind(after, next.kind)
|
||||
collection.set(next)
|
||||
this.dirtyTurnKinds.add(collection)
|
||||
}
|
||||
}
|
||||
|
||||
processSource(key: string): ChatNodeProcessSource {
|
||||
return cachedSource(this.processSources, key, () => new MutableChatSource(
|
||||
() => this.process(key),
|
||||
@@ -112,12 +174,14 @@ class MutableChatNodeStore implements ChatNodeStore {
|
||||
for (const node of nodes) {
|
||||
this.byKey.set(node.key, node)
|
||||
if (previous.get(node.key) !== node) {
|
||||
this.updateTurnKind(previous.get(node.key), node)
|
||||
this.dirtyKeys.add(node.key)
|
||||
this.dirtyProcessKeys.add(node.key)
|
||||
}
|
||||
previous.delete(node.key)
|
||||
}
|
||||
for (const key of previous.keys()) {
|
||||
this.updateTurnKind(previous.get(key), undefined)
|
||||
this.dirtyKeys.add(key)
|
||||
this.dirtyProcessKeys.add(key)
|
||||
}
|
||||
@@ -129,6 +193,7 @@ class MutableChatNodeStore implements ChatNodeStore {
|
||||
let changed = false
|
||||
for (const node of nodes) {
|
||||
if (this.byKey.get(node.key) === node) continue
|
||||
this.updateTurnKind(this.byKey.get(node.key), node)
|
||||
this.byKey.set(node.key, node)
|
||||
this.dirtyKeys.add(node.key)
|
||||
this.dirtyProcessKeys.add(node.key)
|
||||
@@ -154,10 +219,13 @@ class MutableChatNodeStore implements ChatNodeStore {
|
||||
publish(): void {
|
||||
const dirty = [...this.dirtyKeys]
|
||||
const dirtyProcesses = [...this.dirtyProcessKeys]
|
||||
const dirtyTurnKinds = [...this.dirtyTurnKinds]
|
||||
this.dirtyKeys.clear()
|
||||
this.dirtyProcessKeys.clear()
|
||||
this.dirtyTurnKinds.clear()
|
||||
for (const key of dirty) this.sources.get(key)?.publish()
|
||||
for (const key of dirtyProcesses) this.processSources.get(key)?.publish()
|
||||
for (const collection of dirtyTurnKinds) collection.publish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,7 +411,7 @@ function processPresentationInputChanged(
|
||||
|
||||
interface TurnProcessPresentation {
|
||||
readonly control?: ChatNode<'turn-process'>
|
||||
readonly openingHumanAnchor?: number
|
||||
readonly openingInputAnchor?: number
|
||||
readonly earliestProcessAnchor?: number
|
||||
}
|
||||
|
||||
@@ -362,11 +430,13 @@ function turnProcessPresentations(
|
||||
const location = node.location
|
||||
if (location.kind !== 'turn' && location.kind !== 'step') continue
|
||||
const current: TurnProcessPresentation = presentations.get(location.turn.turn) ?? {}
|
||||
if ((node.kind === 'user' || node.kind === 'steering')
|
||||
&& node.anchorSeq < (current.control?.data.controlAnchorSeq ?? Number.POSITIVE_INFINITY)) {
|
||||
const controlAnchor = current.control?.data.controlAnchorSeq
|
||||
if ((node.kind === 'user' || node.kind === 'turn-trigger' || node.kind === 'steering')
|
||||
&& controlAnchor !== undefined
|
||||
&& (controlAnchor === location.turn.start?.seq || node.anchorSeq < controlAnchor)) {
|
||||
presentations.set(location.turn.turn, {
|
||||
...current,
|
||||
openingHumanAnchor: Math.min(current.openingHumanAnchor ?? node.anchorSeq, node.anchorSeq),
|
||||
openingInputAnchor: Math.max(current.openingInputAnchor ?? node.anchorSeq, node.anchorSeq),
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -398,27 +468,27 @@ function presentationPosition(
|
||||
if (presentation === undefined) {
|
||||
return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq }
|
||||
}
|
||||
const openingHumanAnchor = presentation.openingHumanAnchor
|
||||
if (openingHumanAnchor !== undefined
|
||||
&& node.anchorSeq < openingHumanAnchor
|
||||
const openingInputAnchor = presentation.openingInputAnchor
|
||||
if (openingInputAnchor !== undefined
|
||||
&& node.anchorSeq < openingInputAnchor
|
||||
&& !TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) {
|
||||
return { anchor: openingHumanAnchor, rank: 2, originalAnchor: node.anchorSeq }
|
||||
return { anchor: openingInputAnchor, rank: 2, originalAnchor: node.anchorSeq }
|
||||
}
|
||||
if (presentation.control !== undefined && node.key === presentation.control.key) {
|
||||
return openingHumanAnchor === undefined
|
||||
return openingInputAnchor === undefined
|
||||
? {
|
||||
anchor: presentation.earliestProcessAnchor ?? node.anchorSeq,
|
||||
rank: -1,
|
||||
originalAnchor: node.anchorSeq,
|
||||
}
|
||||
: { anchor: openingHumanAnchor, rank: 1, originalAnchor: node.anchorSeq }
|
||||
: { anchor: openingInputAnchor, rank: 1, originalAnchor: node.anchorSeq }
|
||||
}
|
||||
return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq }
|
||||
}
|
||||
|
||||
/**
|
||||
* Order visible Chat Nodes without changing existing relative order as process
|
||||
* eligibility changes. Opening human input precedes process candidates, while
|
||||
* eligibility changes. Opening input precedes process candidates, while
|
||||
* each synthetic process control sits between them.
|
||||
* @param nodes - currently materialized Chat Nodes.
|
||||
* @returns visible Nodes in presentation order.
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
|
||||
interface InboxIdentity {
|
||||
readonly id: string
|
||||
readonly source: { readonly kind: string }
|
||||
}
|
||||
|
||||
interface InboxSplice {
|
||||
@@ -16,7 +17,7 @@ interface InboxSplice {
|
||||
|
||||
interface PendingSnapshot {
|
||||
readonly kind: 'snapshot'
|
||||
readonly ids: readonly string[]
|
||||
readonly ids: readonly InboxIdentity[]
|
||||
}
|
||||
|
||||
interface PendingSplice {
|
||||
@@ -24,23 +25,25 @@ interface PendingSplice {
|
||||
readonly previous: PendingState
|
||||
readonly start: number
|
||||
readonly removedCount: number
|
||||
readonly inserted: readonly string[]
|
||||
readonly inserted: readonly InboxIdentity[]
|
||||
}
|
||||
|
||||
type PendingState = PendingSnapshot | PendingSplice
|
||||
|
||||
/** Persistent next-step state after one durable Inbox splice. */
|
||||
/** Persistent Inbox state after one durable Inbox splice. */
|
||||
export interface InboxState {
|
||||
/** Persistent splice chain materialized only when a next-step batch is claimed. */
|
||||
/** Persistent splice chain materialized only when a batch is claimed. */
|
||||
readonly pending: PendingState
|
||||
/** Message ids in the current claim, shared until the next claim. */
|
||||
readonly currentClaimed: ReadonlySet<string>
|
||||
readonly claimSeq: number
|
||||
readonly claimedHuman: boolean
|
||||
}
|
||||
|
||||
const EMPTY_PENDING: PendingState = { kind: 'snapshot', ids: [] }
|
||||
const EMPTY_CURRENT_CLAIMED: ReadonlySet<string> = new Set()
|
||||
|
||||
function materializePending(state: PendingState): string[] {
|
||||
function materializePending(state: PendingState): InboxIdentity[] {
|
||||
const splices: PendingSplice[] = []
|
||||
let current = state
|
||||
while (current.kind === 'splice') {
|
||||
@@ -56,10 +59,10 @@ function materializePending(state: PendingState): string[] {
|
||||
|
||||
function withoutInserted(
|
||||
claimed: ReadonlySet<string>,
|
||||
inserted: readonly string[],
|
||||
inserted: readonly InboxIdentity[],
|
||||
): ReadonlySet<string> {
|
||||
let next: Set<string> | undefined
|
||||
for (const id of inserted) {
|
||||
for (const { id } of inserted) {
|
||||
if (!claimed.has(id)) continue
|
||||
next ??= new Set(claimed)
|
||||
next.delete(id)
|
||||
@@ -68,7 +71,7 @@ function withoutInserted(
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one next-step splice under the AgentLoop's durable event ordering.
|
||||
* Apply one Inbox splice under the AgentLoop's durable event ordering.
|
||||
* An entered claim logs its complete message batch before another claim; a
|
||||
* rejected claim logs no messages, so only the current claim can classify a
|
||||
* later `user/message`.
|
||||
@@ -76,16 +79,19 @@ function withoutInserted(
|
||||
function applySplice(
|
||||
previous: ConversationPreviousContext<InboxState> | undefined,
|
||||
splice: InboxSplice,
|
||||
seq: number,
|
||||
): InboxState {
|
||||
const priorPending = previous?.state.pending ?? EMPTY_PENDING
|
||||
const inserted = splice.inserted.map(identity => identity.id)
|
||||
const inserted = splice.inserted
|
||||
const removedCount = splice.removedCount ?? 0
|
||||
if (removedCount > 0 && splice.outcome !== 'canceled') {
|
||||
const pending = materializePending(priorPending)
|
||||
const removed = pending.splice(splice.start, removedCount, ...inserted)
|
||||
return {
|
||||
pending: { kind: 'snapshot', ids: pending },
|
||||
currentClaimed: new Set(removed),
|
||||
currentClaimed: new Set(removed.map(message => message.id)),
|
||||
claimSeq: seq,
|
||||
claimedHuman: removed.some(message => message.source.kind === 'user'),
|
||||
}
|
||||
}
|
||||
const currentClaimed = withoutInserted(
|
||||
@@ -101,34 +107,37 @@ function applySplice(
|
||||
inserted,
|
||||
},
|
||||
currentClaimed,
|
||||
claimSeq: previous?.state.claimSeq ?? -1,
|
||||
claimedHuman: previous?.state.claimedHuman ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
const NEXT_STEP_INBOX_KIND = 'inbox-next-step'
|
||||
|
||||
/** Persistent next-step Inbox state used to classify the current claimed batch as steering. */
|
||||
export const nextStepInboxDefinition: ConversationNodeDefinition<InboxState> = {
|
||||
kind: NEXT_STEP_INBOX_KIND,
|
||||
match: (event) => {
|
||||
if (event.type === 'agent/inbox/spliced' && event.data.target === 'next-step') {
|
||||
return { id: String(event.seq), role: 'start' }
|
||||
}
|
||||
return null
|
||||
},
|
||||
start: (_context, match, reader) => {
|
||||
if (match.event.type !== 'agent/inbox/spliced') {
|
||||
throw new Error('inbox-next-step start requires agent/inbox/spliced')
|
||||
}
|
||||
return applySplice(reader.previous<InboxState>(NEXT_STEP_INBOX_KIND), match.event.data)
|
||||
},
|
||||
update: context => context.state,
|
||||
publication: () => 'none',
|
||||
function inboxDefinition(target: 'next-step' | 'next-turn'): ConversationNodeDefinition<InboxState> {
|
||||
const kind = `inbox-${target}`
|
||||
return {
|
||||
kind,
|
||||
match: event => event.type === 'agent/inbox/spliced' && event.data.target === target
|
||||
? { id: String(event.seq), role: 'start' } : null,
|
||||
start: (_context, match, reader) => {
|
||||
if (match.event.type !== 'agent/inbox/spliced') throw new Error('inbox start requires agent/inbox/spliced')
|
||||
return applySplice(reader.previous<InboxState>(kind), match.event.data, match.event.seq)
|
||||
},
|
||||
update: context => context.state,
|
||||
publication: () => 'none',
|
||||
}
|
||||
}
|
||||
|
||||
/** Persistent next-step claims identify messages admitted into a running Turn. */
|
||||
export const nextStepInboxDefinition = inboxDefinition('next-step')
|
||||
|
||||
/** Persistent next-turn claims identify messages that wake a new Turn. */
|
||||
export const nextTurnInboxDefinition = inboxDefinition('next-turn')
|
||||
|
||||
/**
|
||||
* Register the next-step Inbox state used by Chat message classification.
|
||||
* Register the Inbox state used by Chat message classification.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerInboxConversationNodes(ctx: Context): void {
|
||||
ctx.uiConversation.events.register(nextStepInboxDefinition)
|
||||
ctx.uiConversation.events.register(nextTurnInboxDefinition)
|
||||
}
|
||||
@@ -21,7 +21,7 @@ interface ReferencedSteeringMessageNode extends SteeringMessageNode {
|
||||
readonly skillNames?: readonly string[]
|
||||
}
|
||||
|
||||
type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | ContextMessageNode
|
||||
type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | (ContextMessageNode & { readonly waking?: boolean })
|
||||
|
||||
declare module '../contract/chat-nodes.ts' {
|
||||
interface ChatNodeDataMap {
|
||||
@@ -31,6 +31,8 @@ declare module '../contract/chat-nodes.ts' {
|
||||
steering: ReferencedSteeringMessageNode
|
||||
/** Non-user context injected into model history. */
|
||||
context: ContextMessageNode
|
||||
/** Non-human input that starts a Turn. */
|
||||
'turn-trigger': ContextMessageNode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +60,20 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
|
||||
if (match.event.type !== 'user/message') throw new Error('input-message start requires user/message')
|
||||
const event = match.event
|
||||
if (event.data.source.kind !== 'user') {
|
||||
const nextTurn = reader.previous<InboxState>('inbox-next-turn')?.state
|
||||
const nextStep = reader.previous<InboxState>('inbox-next-step')?.state
|
||||
const location = match.location
|
||||
const turnStart = location.kind === 'step' ? location.turn.start?.seq : undefined
|
||||
// An idle steer opens Step 1 without a next-turn claim in this Turn.
|
||||
// A human in that same next-step claim owns the opening instead of its notices.
|
||||
const idleSteer = location.kind === 'step' && location.step.step === 1
|
||||
&& turnStart !== undefined && (nextStep?.claimSeq ?? -1) > turnStart
|
||||
&& (nextTurn?.claimSeq ?? -1) < turnStart && nextStep?.claimedHuman === false
|
||||
&& nextStep.currentClaimed.has(String(event.data.id))
|
||||
|
||||
return {
|
||||
kind: 'context',
|
||||
waking: nextTurn?.currentClaimed.has(String(event.data.id)) === true || idleSteer,
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
content: event.data.content,
|
||||
@@ -90,7 +104,10 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
|
||||
update: context => context.state,
|
||||
buildViewNode: (context) => {
|
||||
if (context.state === undefined) return null
|
||||
return chatNode(context, context.state.kind, context.state.seq, context.state)
|
||||
const waking = context.state.kind === 'context'
|
||||
&& context.start?.event.type === 'user/message'
|
||||
&& context.state.waking === true
|
||||
return chatNode(context, waking ? 'turn-trigger' : context.state.kind, context.state.seq, context.state)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/** Tool-category and live-detail interpretation owned by Chat grouping. */
|
||||
import type { ProcessActivity, ProcessActivitySummary } from '../contract/process-groups.ts'
|
||||
import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
import { isRunningTool } from '../contract/chat-nodes.ts'
|
||||
import type { ToolCallBlock } from '../contract/snapshot.ts'
|
||||
|
||||
function activity(name: string): ProcessActivity {
|
||||
if (['read', 'read_image', 'list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource'].includes(name)) return 'read'
|
||||
if (name === 'grep' || name === 'glob' || name.endsWith('_inspect')) return 'search'
|
||||
if (['write', 'edit', 'apply_patch'].includes(name)) return 'edit'
|
||||
if (['bash', 'pwsh', 'exec_command', 'write_stdin'].includes(name) || name.startsWith('terminal_')) return 'commands'
|
||||
if (name === 'run_code') return 'code'
|
||||
if (name === 'web_search') return 'webSearch'
|
||||
if (name === 'web_fetch') return 'webFetch'
|
||||
if (name === 'subagent' || name.startsWith('subagent_')) return 'subagents'
|
||||
if (['todo_write', 'create_goal', 'update_goal', 'get_goal'].includes(name)) return 'plan'
|
||||
if (name === 'ask_user_question' || name === 'request_user_input') return 'questions'
|
||||
return 'tools'
|
||||
}
|
||||
|
||||
const LIVE_TOOL_DETAIL_MAX_CHARS = 160
|
||||
const LIVE_TOOL_DETAIL_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
const LIVE_TOOL_DETAIL_KEYS = [
|
||||
'title', 'description', 'objective', 'task', 'task_name', 'name', 'question', 'questions', 'prompt', 'message',
|
||||
'command', 'cmd', 'queries', 'query', 'pattern', 'url', 'uri', 'file_path', 'path', 'target', 'action', 'status',
|
||||
] as const
|
||||
|
||||
function normalizeLiveToolDetail(value: unknown): string {
|
||||
const text = typeof value === 'string'
|
||||
? value
|
||||
: Array.isArray(value) && value.every(item => typeof item === 'string')
|
||||
? value.join(', ')
|
||||
: ''
|
||||
const normalized = text.replace(/\s+/g, ' ').trim()
|
||||
const chars = Array.from(LIVE_TOOL_DETAIL_SEGMENTER.segment(normalized), part => part.segment)
|
||||
return chars.length <= LIVE_TOOL_DETAIL_MAX_CHARS
|
||||
? normalized
|
||||
: `${chars.slice(0, LIVE_TOOL_DETAIL_MAX_CHARS - 1).join('').trimEnd()}…`
|
||||
}
|
||||
|
||||
function questionDetail(value: unknown): string {
|
||||
if (!Array.isArray(value)) return ''
|
||||
for (const item of value) {
|
||||
if (item === null || typeof item !== 'object') continue
|
||||
const detail = normalizeLiveToolDetail(Reflect.get(item, 'question'))
|
||||
if (detail !== '') return detail
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function liveReasoningDetail(nodes: readonly ChatNode[]): string {
|
||||
for (let nodeIndex = nodes.length - 1; nodeIndex >= 0; nodeIndex--) {
|
||||
const node = nodes[nodeIndex]
|
||||
if (node?.kind !== 'assistant-step' || node.data.status !== 'running') continue
|
||||
for (let blockIndex = node.data.blocks.length - 1; blockIndex >= 0; blockIndex--) {
|
||||
const block = node.data.blocks[blockIndex]
|
||||
if (block?.kind !== 'reasoning') continue
|
||||
const paragraphs = block.text.split(/\r?\n[\t ]*\r?\n/)
|
||||
for (let paragraphIndex = paragraphs.length - 1; paragraphIndex >= 0; paragraphIndex--) {
|
||||
const detail = normalizeLiveToolDetail(paragraphs[paragraphIndex]?.replaceAll('**', ''))
|
||||
if (detail !== '') return detail
|
||||
}
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function liveToolDetail(name: string, argsRaw: string): string {
|
||||
let args: unknown
|
||||
try {
|
||||
args = JSON.parse(argsRaw)
|
||||
} catch (_error: unknown) {
|
||||
// Partial or free-form arguments have no safe one-line task detail.
|
||||
return normalizeLiveToolDetail(name)
|
||||
}
|
||||
if (args === null || typeof args !== 'object') return normalizeLiveToolDetail(name)
|
||||
for (const key of LIVE_TOOL_DETAIL_KEYS) {
|
||||
if (key in args) {
|
||||
const value: unknown = Reflect.get(args, key)
|
||||
const detail = key === 'questions' ? questionDetail(value) : normalizeLiveToolDetail(value)
|
||||
if (detail !== '') return detail
|
||||
}
|
||||
}
|
||||
return normalizeLiveToolDetail(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank categories by distinct call count, breaking ties by first appearance.
|
||||
* @param nodes - process members, including recursive tools.
|
||||
* @returns all ranked categories and the latest running tool category and bounded task detail.
|
||||
*/
|
||||
export function processActivity(nodes: readonly ChatNode[]): ProcessActivitySummary {
|
||||
const counts = new Map<ProcessActivity, number>()
|
||||
const seen = new Set<string>()
|
||||
let running: ProcessActivity | undefined
|
||||
let runningDetail = ''
|
||||
let runningTime = -Infinity
|
||||
const visit = (tool: ToolCallBlock): void => {
|
||||
if (seen.has(tool.callId)) return
|
||||
seen.add(tool.callId)
|
||||
const call = isRunningTool(tool) ? tool : tool.call
|
||||
if (call !== null) {
|
||||
const kind = activity(call.name)
|
||||
if (isRunningTool(tool) && tool.time >= runningTime) {
|
||||
running = kind
|
||||
runningDetail = liveToolDetail(tool.name, tool.argsRaw)
|
||||
runningTime = tool.time
|
||||
}
|
||||
counts.set(kind, (counts.get(kind) ?? 0) + 1)
|
||||
}
|
||||
for (const child of tool.subCalls) visit(child)
|
||||
}
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'tool-call') visit(node.data.root)
|
||||
}
|
||||
if (running === undefined) runningDetail = liveReasoningDetail(nodes)
|
||||
return {
|
||||
counts: [...counts].map(([kind, count]) => ({ kind, count })).sort((a, b) => b.count - a.count),
|
||||
running,
|
||||
runningDetail,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/** Chat-owned segmentation and incremental summaries over materialized Node inputs. */
|
||||
import { brandString } from '@deepseek-ai/dsh-brand'
|
||||
import type {
|
||||
ConversationGroupDefinition, ConversationGroupInput, GroupKey, GroupSnapshot,
|
||||
GroupUpdate, NodeKey, NodeReference, RenderEntry,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts'
|
||||
import type { ProcessActivitySummary, ProcessGroupData } from '../contract/process-groups.ts'
|
||||
import { hasAssistantReplyContent } from '../contract/assistant-content.ts'
|
||||
import { isVisibleChatNode } from '../contract/chat-visibility.ts'
|
||||
import { processActivity } from './process-activity.ts'
|
||||
|
||||
const INDEPENDENT = new Set(['user', 'steering', 'turn-trigger', 'model-retry', 'turn-error', 'turn-max-tokens', 'turn-tail'])
|
||||
type ProcessInput = ConversationGroupInput<ChatConversationViewNode>
|
||||
|
||||
function turnOf(node: ChatNode): number | undefined {
|
||||
const location = node.location
|
||||
return location.kind === 'turn' || location.kind === 'step' ? location.turn.turn : undefined
|
||||
}
|
||||
|
||||
function reasoning(node: ChatNode): boolean {
|
||||
return node.kind === 'assistant-step'
|
||||
&& node.data.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '')
|
||||
}
|
||||
|
||||
function reply(node: ChatNode): boolean {
|
||||
return node.kind === 'assistant-step' && hasAssistantReplyContent(node.data.blocks)
|
||||
}
|
||||
|
||||
function sameSummary(left: ProcessActivitySummary, right: ProcessActivitySummary): boolean {
|
||||
return left.running === right.running && left.runningDetail === right.runningDetail
|
||||
&& left.counts.length === right.counts.length && left.counts.every((value, index) =>
|
||||
value.kind === right.counts[index]?.kind && value.count === right.counts[index].count)
|
||||
}
|
||||
|
||||
function sameMembers(left: readonly NodeReference[], right: readonly NodeReference[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) =>
|
||||
value.key === right[index]?.key && value.groupPart === right[index].groupPart)
|
||||
}
|
||||
|
||||
function structureChanged(previous: ChatNode | undefined, current: ChatNode): boolean {
|
||||
if (!isVisibleChatNode(current) && (previous === undefined || !isVisibleChatNode(previous))) return false
|
||||
return previous === undefined || previous.kind !== current.kind
|
||||
|| turnOf(previous) !== turnOf(current)
|
||||
|| isVisibleChatNode(previous) !== isVisibleChatNode(current)
|
||||
|| reasoning(previous) !== reasoning(current) || reply(previous) !== reply(current)
|
||||
}
|
||||
|
||||
function readNode(input: ProcessInput, key: NodeKey): ChatNode {
|
||||
const node = input.readNode(key) as ChatNode | undefined
|
||||
if (node === undefined) throw new Error(`Chat grouping input is missing Node ${key}`)
|
||||
return node
|
||||
}
|
||||
|
||||
/** One group's members and cached summary, refreshed together when its content changes. */
|
||||
class ProcessGroup {
|
||||
private nodes: readonly ChatNode[] = []
|
||||
snapshot: GroupSnapshot<ProcessGroupData>
|
||||
|
||||
constructor(readonly key: GroupKey, readonly turn: number, readonly members: readonly NodeReference[]) {
|
||||
this.snapshot = { key, members, data: { turn, closed: false, summary: { counts: [], running: undefined, runningDetail: '' } } }
|
||||
}
|
||||
|
||||
refresh(input: ProcessInput, closed: boolean): void {
|
||||
const nodes = this.members.map(member => readNode(input, member.key))
|
||||
const unchanged = nodes.length === this.nodes.length && nodes.every((node, index) => node === this.nodes[index])
|
||||
const previous = this.snapshot.data
|
||||
const activity = unchanged && previous.closed === closed ? previous.summary : processActivity(nodes)
|
||||
const summary = closed ? { ...activity, running: undefined, runningDetail: '' } : activity
|
||||
this.nodes = nodes
|
||||
if (previous.closed !== closed || !sameSummary(previous.summary, summary)) {
|
||||
this.snapshot = {
|
||||
key: this.key, members: this.members,
|
||||
data: { turn: this.turn, closed, summary },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One Turn's grouping result and member lookup; summaries stay with their groups. */
|
||||
class TurnGroups {
|
||||
private groups = new Map<GroupKey, ProcessGroup>()
|
||||
private membership = new Map<NodeKey, GroupKey>()
|
||||
private roots = new Map<NodeKey, readonly RenderEntry[]>()
|
||||
|
||||
constructor(readonly turn: number) {}
|
||||
|
||||
references(key: NodeKey): readonly RenderEntry[] {
|
||||
return this.roots.get(key) ?? []
|
||||
}
|
||||
|
||||
snapshots(): GroupSnapshot<ProcessGroupData>[] {
|
||||
return [...this.groups.values()].map(group => group.snapshot)
|
||||
}
|
||||
|
||||
refresh(input: ProcessInput, changed: ReadonlySet<NodeKey>): GroupSnapshot<ProcessGroupData>[] {
|
||||
const dirty = new Set<GroupKey>()
|
||||
for (const node of changed) {
|
||||
const group = this.membership.get(node)
|
||||
if (group !== undefined) dirty.add(group)
|
||||
}
|
||||
const ended = input.timeline.turns.get(this.turn)?.status === 'closed'
|
||||
if (ended) {
|
||||
for (const group of this.groups.values()) {
|
||||
if (!group.snapshot.data.closed) dirty.add(group.key)
|
||||
}
|
||||
}
|
||||
const upserts: GroupSnapshot<ProcessGroupData>[] = []
|
||||
for (const key of dirty) {
|
||||
const group = this.groups.get(key) as ProcessGroup
|
||||
const previous = group.snapshot
|
||||
group.refresh(input, previous.data.closed || ended)
|
||||
if (group.snapshot !== previous) upserts.push(group.snapshot)
|
||||
}
|
||||
return upserts
|
||||
}
|
||||
|
||||
rebuild(input: ProcessInput, added: ReadonlySet<NodeKey>): { upserts: GroupSnapshot<ProcessGroupData>[]; removes: GroupKey[] } {
|
||||
const roots = new Map<NodeKey, readonly RenderEntry[]>()
|
||||
const groups = new Map<GroupKey, ProcessGroup>()
|
||||
const membership = new Map<NodeKey, GroupKey>()
|
||||
let pending: NodeReference[] = []
|
||||
const upserts: GroupSnapshot<ProcessGroupData>[] = []
|
||||
const emit = (key: NodeKey, entry: RenderEntry): void => {
|
||||
roots.set(key, [...roots.get(key) ?? [], entry])
|
||||
}
|
||||
const flush = (closed: boolean): void => {
|
||||
const first = pending[0]
|
||||
if (first === undefined) return
|
||||
const retained = this.extendedGroup(pending, added)
|
||||
const key = retained?.key ?? brandString<GroupKey>(JSON.stringify(['process', first.key, first.groupPart ?? null]))
|
||||
const previous = this.groups.get(key)
|
||||
const before = previous?.snapshot
|
||||
const group = previous !== undefined && sameMembers(previous.members, pending)
|
||||
? previous : new ProcessGroup(key, this.turn, pending)
|
||||
group.refresh(input, closed || input.timeline.turns.get(this.turn)?.status === 'closed')
|
||||
groups.set(group.key, group)
|
||||
emit(first.key, { kind: 'group', key: group.key })
|
||||
for (const member of pending) membership.set(member.key, group.key)
|
||||
if (group.snapshot !== before) upserts.push(group.snapshot)
|
||||
pending = []
|
||||
}
|
||||
let previous: NodeKey | undefined
|
||||
let followed = false
|
||||
for (const key of input.readTurn(this.turn)) {
|
||||
const position = readPosition(input, key)
|
||||
if (previous !== undefined && position.previous !== previous) flush(true)
|
||||
previous = key
|
||||
followed = position.next !== undefined
|
||||
const node = readNode(input, key)
|
||||
if (INDEPENDENT.has(node.kind)) {
|
||||
flush(true)
|
||||
emit(key, { kind: 'node', key })
|
||||
} else if (node.kind === 'turn-process') {
|
||||
emit(key, { kind: 'node', key })
|
||||
} else if (node.kind === 'assistant-step') {
|
||||
if (reasoning(node)) pending.push({ kind: 'node', key, groupPart: 'reasoning' })
|
||||
if (reply(node)) {
|
||||
flush(true)
|
||||
emit(key, { kind: 'node', key, groupPart: 'response' })
|
||||
}
|
||||
} else pending.push({ kind: 'node', key })
|
||||
}
|
||||
flush(followed)
|
||||
const removes = [...this.groups.keys()].filter(key => !groups.has(key))
|
||||
this.groups = groups
|
||||
this.membership = membership
|
||||
this.roots = roots
|
||||
return { upserts, removes }
|
||||
}
|
||||
|
||||
private extendedGroup(members: readonly NodeReference[], added: ReadonlySet<NodeKey>): ProcessGroup | undefined {
|
||||
// Reuse identity only while the complete old group remains between newly visible members.
|
||||
const offset = members.findIndex(member => !added.has(member.key))
|
||||
const first = members[offset]
|
||||
if (first === undefined) return undefined
|
||||
const key = this.membership.get(first.key)
|
||||
const previous = key === undefined ? undefined : this.groups.get(key)
|
||||
if (previous === undefined || offset + previous.members.length > members.length) return undefined
|
||||
for (let index = 0; index < previous.members.length; index++) {
|
||||
const before = previous.members[index] as NodeReference
|
||||
const after = members[offset + index] as NodeReference
|
||||
if (before.key !== after.key || before.groupPart !== after.groupPart) return undefined
|
||||
}
|
||||
for (let index = offset + previous.members.length; index < members.length; index++) {
|
||||
if (!added.has((members[index] as NodeReference).key)) return undefined
|
||||
}
|
||||
return previous
|
||||
}
|
||||
}
|
||||
|
||||
function readPosition(input: ProcessInput, key: NodeKey) {
|
||||
const position = input.readPosition(key)
|
||||
if (position === undefined) throw new Error(`Chat grouping order is missing position for Node ${key}`)
|
||||
return position
|
||||
}
|
||||
|
||||
/** Session-local Turn results; ordinary updates never read other Turns' Node contents. */
|
||||
export class ProcessState {
|
||||
private turns = new Map<number, TurnGroups>()
|
||||
private order: readonly NodeKey[] = []
|
||||
private pending: GroupUpdate<ProcessGroupData> | null = null
|
||||
|
||||
/**
|
||||
* Consume one synchronous Builder input without retaining its readers.
|
||||
* @param input - projected Node changes, indexed positions, and Turn lifecycle.
|
||||
*/
|
||||
accept(input: ProcessInput): void {
|
||||
if (input.kind === 'replace') {
|
||||
const previousKeys = new Set(this.order)
|
||||
const added = new Set(input.order.filter(key => !previousKeys.has(key)))
|
||||
const turns = new Map<number, TurnGroups>()
|
||||
for (const key of input.order) {
|
||||
const turn = readPosition(input, key).turn
|
||||
if (turn === undefined || turns.has(turn)) continue
|
||||
const groups = this.turns.get(turn) ?? new TurnGroups(turn)
|
||||
groups.rebuild(input, added)
|
||||
turns.set(turn, groups)
|
||||
}
|
||||
this.turns = turns
|
||||
this.order = input.order
|
||||
this.pending = {
|
||||
entries: this.rootEntries(input),
|
||||
groups: { kind: 'replace', snapshots: [...turns.values()].flatMap(turn => turn.snapshots()) },
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const regroup = new Set(input.changedTurnOrders)
|
||||
const added = new Set<NodeKey>()
|
||||
const changed = new Map<number, Set<NodeKey>>()
|
||||
const touch = (turn: number): Set<NodeKey> => {
|
||||
let keys = changed.get(turn)
|
||||
if (keys === undefined) {
|
||||
keys = new Set()
|
||||
changed.set(turn, keys)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
for (const change of input.changes) {
|
||||
const before = change.previous as ChatNode | undefined
|
||||
const after = change.current as ChatNode
|
||||
const turn = turnOf(after)
|
||||
if (before === undefined || !isVisibleChatNode(before)) added.add(after.key as NodeKey)
|
||||
if (structureChanged(before, after)) {
|
||||
const previousTurn = before === undefined ? undefined : turnOf(before)
|
||||
if (previousTurn !== undefined) regroup.add(previousTurn)
|
||||
if (turn !== undefined) regroup.add(turn)
|
||||
}
|
||||
if (turn !== undefined) touch(turn).add(after.key as NodeKey)
|
||||
}
|
||||
for (const turn of input.changedTurns) touch(turn)
|
||||
|
||||
const upserts: GroupSnapshot<ProcessGroupData>[] = []
|
||||
const removes: GroupKey[] = []
|
||||
for (const turn of regroup) {
|
||||
const groups = this.turns.get(turn) ?? new TurnGroups(turn)
|
||||
const update = groups.rebuild(input, added)
|
||||
upserts.push(...update.upserts)
|
||||
removes.push(...update.removes)
|
||||
if (input.readTurn(turn).length === 0) this.turns.delete(turn)
|
||||
else this.turns.set(turn, groups)
|
||||
}
|
||||
for (const [turn, keys] of changed) {
|
||||
if (!regroup.has(turn)) upserts.push(...this.turns.get(turn)?.refresh(input, keys) ?? [])
|
||||
}
|
||||
const reordered = input.order !== this.order || regroup.size > 0
|
||||
this.order = input.order
|
||||
const installed = new Set(upserts.map(group => group.key))
|
||||
this.pending = reordered || upserts.length > 0 || removes.length > 0
|
||||
? {
|
||||
...reordered ? { entries: this.rootEntries(input) } : {},
|
||||
groups: { kind: 'apply', upserts, removes: removes.filter(key => !installed.has(key)) },
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
private rootEntries(input: ProcessInput): RenderEntry[] {
|
||||
return input.order.flatMap<RenderEntry>((key) => {
|
||||
const turn = readPosition(input, key).turn
|
||||
if (turn === undefined) return [{ kind: 'node', key }]
|
||||
const groups = this.turns.get(turn)
|
||||
if (groups === undefined) throw new Error(`Chat grouping order is missing Turn ${turn}`)
|
||||
return groups.references(key)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Read pending output without advancing State.
|
||||
* @returns the repeatable update for the last input batch.
|
||||
*/
|
||||
output(): GroupUpdate<ProcessGroupData> | null { return this.pending }
|
||||
}
|
||||
|
||||
/** Chat's registered business grouping; presentation modes never enter its State. */
|
||||
export const processGroupDefinition: ConversationGroupDefinition<ChatConversationViewNode, ProcessState, ProcessGroupData>
|
||||
& { readonly target: 'chat' } = {
|
||||
kind: 'process-groups', target: 'chat',
|
||||
create: () => new ProcessState(),
|
||||
update: (context, input) => { context.state.accept(input); return context.state },
|
||||
buildGroups: context => context.state.output(),
|
||||
}
|
||||
Loaded 100 of 241 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user