Merge remote-tracking branch 'origin/master' into feat/page-font-size-shortcuts

This commit is contained in:
fz committed 2026-10-13 00:44:27 +08:00
commit 0f0b30b3aa
62 files changed
+858 -248

No files matched your search

@@ -15,8 +15,8 @@
en: adccc5dc758ad8f6
zh: 50304d68db42e8b1
/agent-note-timed-user-questions-as-two-settlements/decision/ownership:
en: 0543678ad9d26fc5
zh: aa6ba207e661c567
en: a28e37fda6e9eaff
zh: 9ca622e09ab24b4d
/agent-note-timed-user-questions-as-two-settlements/decision/foreground-wait-and-claim-lifetime:
en: 447713bbdf1ab4d8
zh: 2757678ae47822aa
@@ -30,14 +30,14 @@
en: 3a33f2583e30e32a
zh: 0ad7e8d1e47dc510
/agent-note-timed-user-questions-as-two-settlements/decision/reply-node-and-grouping:
en: 10323b0e0340a40b
zh: a17fc3a4d4d820a7
en: d87d7432c37405a7
zh: 49f1bbf53856851a
/agent-note-timed-user-questions-as-two-settlements/alternatives-considered:
en: bf61d0ec80c63bb4
zh: d4ce4d4438ffb013
en: 1a070542bfa40e68
zh: 82e32964da912dd8
/agent-note-timed-user-questions-as-two-settlements/verification:
en: d912a73a5ff409eb
zh: 7b626794773e2301
en: 1d19d196c363a273
zh: 7cb0475f0cb554d1
/agent-note-timed-user-questions-as-two-settlements/consequences:
en: 2bc99c99be8ca9da
zh: 8f262ec8b9b0945e
en: 0580e85cc12439c5
zh: a4b151d2c9e5b747
@@ -29,7 +29,7 @@ Foreground answers use the existing Remote Event waterfall. A timeout ends that
| `UserQuestionService` | Foreground waits and Client claims, unattended deadlines, late-answer validation and steering, and the durable question projection. |
| Gateway and Remote runtime | Existing request delivery, settlement, cancellation, and Remote stream transport; no question-specific timer or event-name branch. |
| `ui-user-questions` | One card per Session and call, answer-channel selection, local countdown and drafts, and the reply Definition and renderer. |
| `ui-chat` | Message-id-based presentation aggregation and ordinary process/Turn folding. |
| `ui-chat` | Ordinary process/Turn folding. |
| Question-specific tool row | Pending/answered presentation and panel actions, composed in `QuestionToolRow` without adding `rowAction` to generic `ToolRow`. |
The [question service](../../../../packages/interaction/user-questions/README.md), [tool](../../../../packages/interaction/tool-ask-user/README.md), and [questions UI](../../../../packages/client/ui-user-questions/README.md) own their package contracts.
@@ -72,11 +72,11 @@ Autofocus applies only when the answer channel is ready, no countdown is active,
The questions UI identifies a late reply from `source.kind`, then parses its recorded JSON for question-and-answer presentation. Pasting identical JSON in an ordinary user message does not identify it as a reply. Unreadable recorded payloads retain their raw-text presentation.
The ordinary message Definition and the question-reply Definition both match the appended message and retain separate Node Store projections with the same message id. Chat business grouping omits the `turn-trigger` presentation when a `question-reply` with that id is present. The duplicate trigger neither renders nor splits a group; the reply renders once. The same aggregation applies to unscoped historical input.
The question-reply Definition claims the appended reply message on the Chat target through an [exclusive match](2026-10-12-exclusive-conversation-matches.md), so the ordinary message Definition builds no `turn-trigger` or context projection for it and the reply renders once, including for unscoped historical input.
The reply participates in ordinary process grouping and whole-Turn folding. It is not independently visible: reading it can require expanding both its Turn and process group. Turn disclosure stays before the group it controls, including a reply that opens the Turn.
This decision adds no process-role field, independent-layout flag, user-message structure change, or target-local fallback dispatch. The independent-kind rules stay in Chat's existing grouping logic. A node-data-owned declaration of grouping and overlap semantics is deferred infrastructure, not part of the question feature.
This decision adds no independent-layout flag or user-message structure change. The reply declares no group-tree position, so it follows the default placement of process content.
## Alternatives considered
@@ -90,7 +90,7 @@ This decision adds no process-role field, independent-layout flag, user-message
**Reclassify JSON inside generic `MessageItem`, borrow the relay form, or use the default context row.** Parsing text to identify the business message confuses its source with its content; the relay form describes another agent's message, and a generic context row exposes JSON. A dedicated source, Definition, and renderer own the question presentation.
**Independent reply layout through generic node metadata.** It broadens a question feature into a conversation-infrastructure change. Ordinary folding and Chat-owned aggregation are sufficient for this decision; independently declared node-data grouping semantics remain a separate design task.
**Independent reply layout through generic node metadata.** It broadens a question feature into a conversation-infrastructure change. Ordinary folding is sufficient for this decision; placing the reply as user input through its group-tree position is separate work.
**A log-only close event or a manufactured dismissal message on panel close.** A log-only close hides the user's decision from the model and adds another durable mechanism. Manufacturing a reply treats hiding the panel as an answer. Panel close therefore leaves a timed question answerable.
@@ -101,7 +101,7 @@ This decision adds no process-role field, independent-layout flag, user-message
- [Service tests](../../../../packages/interaction/user-questions/tests/user-questions.spec.ts) and [wait tests](../../../../packages/interaction/user-questions/tests/timed-wait.spec.ts) cover claimed and unclaimed waits, last-claim disconnect, cancellation, timeout error mapping, continued-only answers, and exact answer-id validation.
- [Projection tests](../../../../packages/interaction/user-questions/tests/projection.spec.ts) distinguish legacy and timed schemas and cover native and PTC pending calls, other failures, resume repair, queued reply cancellation, and admitted settlement.
- [Client listener tests](../../../../packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts) and [composer tests](../../../../packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx) cover claim retention through Host acceptance, focus before handshake, indefinite waits, draft retention, RPC resubmission, and panel actions.
- [Grouping tests](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts) and [browser replay](../../../../apps/web/tests/question-composer.e2e.ts) cover one rendered reply with both projections retained, ordinary collapsed placement, disclosure ordering, and the original tool row's read-only late-answer panel.
- [Grouping tests](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts) and [browser replay](../../../../apps/web/tests/question-composer.e2e.ts) cover ordinary collapsed placement, disclosure ordering, and the original tool row's read-only late-answer panel.
- Legacy round-trip, cancellation, plan-review intent, and Session-remount regressions remain covered by their owning tests. Live multi-Client delivery races and real Host restart remain integration coverage gaps beyond isolated lifecycle and projection tests.
## Consequences
@@ -111,7 +111,6 @@ The model can continue independent work while the question remains answerable. B
- Take time affects only one Client. Another Client can answer or expire the shared waterfall; local focus does not establish a global hold.
- A terminal claim-stream failure can reject the foreground question rather than produce pending. No unattended-wait fallback is provided for that failure.
- Host and Client clocks need not align, but Client system-clock changes can move expiry and transport latency can delay the local countdown's start.
- The duplicate projections remain stored. Aggregation adds linear scans during structural grouping rebuilds; ordinary text-only streaming updates retain their incremental path. This is an algorithmic bound, not a measured latency claim.
- Unanswered continued questions can remain after reopening a Session. Panel close records nothing, and visibility follows normal folding rather than an always-visible reply guarantee.
- A queued late reply can be canceled before the agent admits it. The question stays answerable until an admitted `user/message` records the answer.
- A PTC sub-call interrupted before its `tool/ptc-dispatch` result is recorded cannot be reconstructed from the parent `run_code` failure, so it cannot take a late answer after recovery.
@@ -29,7 +29,7 @@ Status: implemented
| `UserQuestionService` | 前台等待与 Client 接手、无人接手时的 deadline、迟到回答校验与 steering(中途引导),以及持久问题投影。 |
| Gateway 与 Remote 运行时 | 现有请求投递、结算、取消及 Remote stream 传输;不包含问题专属计时器或按事件名分支。 |
| `ui-user-questions` | 每个 Session 和调用的一张卡片、回答通道选择、本地倒计时与草稿,以及回复 Definition 和渲染器。 |
| `ui-chat` | 基于消息 id 的呈现聚合,以及普通过程/轮次折叠。 |
| `ui-chat` | 普通过程/轮次折叠。 |
| 问题专用工具行 | pending/已回答呈现与面板操作,在 `QuestionToolRow` 中组合,不向通用 `ToolRow` 添加 `rowAction`。 |
[问题服务](../../../../packages/interaction/user-questions/README.zh.md)、[工具](../../../../packages/interaction/tool-ask-user/README.zh.md)和[问题 UI](../../../../packages/client/ui-user-questions/README.zh.md)分别拥有各包的约定。
@@ -72,11 +72,11 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会
问题 UI 通过 `source.kind` 识别迟到回复,再解析已记录的 JSON 来呈现问答。普通用户消息粘贴相同 JSON 不会被识别为回复。无法读取的已记录载荷保留原始文本呈现。
普通消息 Definition 和问答回复 Definition 都匹配追加的消息,在 Node Store 中保留消息 id 相同的独立投影。存在同 id 的 `question-reply` 时,Chat 业务分组省略 `turn-trigger` 呈现。重复触发节点既不渲染,也不切开分组,回复只显示一次。相同聚合也适用于无轮次归属的历史输入。
问答回复 Definition 通过[独占匹配](2026-10-12-exclusive-conversation-matches.zh.md)在 Chat target 上认领追加的回复消息,普通消息 Definition 因此不再为它建立 `turn-trigger` 或 context 投影,回复只显示一次;无轮次归属的历史输入同样如此。
回复参与普通过程分组和整轮折叠,不保证独立可见:阅读时可能需要展开所属轮次与过程分组。轮次折叠控件始终在所控制的分组之前,包括由回复开启轮次的情况。
本决策不添加 process-role 字段、独立布局标记、用户消息结构变更或目标内 fallback 派发。独立 kind 规则保留在 Chat 现有分组逻辑中。由节点 data 声明分组与重叠语义的机制是留待拆分的基础设施,不属于问题功能。
本决策不添加独立布局标记或用户消息结构变更。回复不声明分组树位置,因此按过程内容的默认位置放置。
## Alternatives considered
@@ -90,7 +90,7 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会
**在通用 `MessageItem` 内重新分类 JSON、借用 relay form,或使用默认 context 行。** 解析文本来识别业务消息混淆了来源与内容;relay form 表示其他 agent 的消息,通用 context 行则会暴露 JSON。专用来源、Definition 和渲染器拥有问题呈现。
**通过通用节点元数据让回复独立布局。** 这会把问题功能扩大为对话基础设施变更。普通折叠与 Chat 内聚合足以满足本决策;由节点 data 独立声明分组语义仍是单独的设计任务。
**通过通用节点元数据让回复独立布局。** 这会把问题功能扩大为对话基础设施变更。普通折叠足以满足本决策;通过分组树位置把回复放置为用户输入是单独的工作。
**面板关闭时写仅入日志的关闭事件,或伪造放弃消息。** 仅入日志会让模型看不到用户决定,还增加另一套持久机制。伪造回复则把隐藏面板当成回答。因此关闭面板仍让计时问题保持可回答。
@@ -101,7 +101,7 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会
- [服务测试](../../../../packages/interaction/user-questions/tests/user-questions.spec.ts)与[等待测试](../../../../packages/interaction/user-questions/tests/timed-wait.spec.ts)覆盖有人与无人接手、最后接手方断开、取消、超时错误映射、仅接受已继续问题的回答,以及答案 id 的精确校验。
- [投影测试](../../../../packages/interaction/user-questions/tests/projection.spec.ts)区分 legacy 与 timed schema,并覆盖原生及 PTC pending 调用、其他失败、恢复修复、排队回复取消和准入后结算。
- [Client 监听器测试](../../../../packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts)与[composer 测试](../../../../packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx)覆盖接手保留到 Host 接受结果、握手前聚焦、无限期等待、草稿保留、RPC 重提及面板操作。
- [分组测试](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts)与[浏览器回放](../../../../apps/web/tests/question-composer.e2e.ts)覆盖保留两个投影但只渲染一次回复、普通折叠位置、折叠控件顺序,以及原工具行的迟到答案只读面板。
- [分组测试](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts)与[浏览器回放](../../../../apps/web/tests/question-composer.e2e.ts)覆盖普通折叠位置、折叠控件顺序,以及原工具行的迟到答案只读面板。
- legacy 问答往返、取消、计划评审意图及 Session 重新挂载的回归仍由各自测试覆盖。真实多 Client 投递竞争与真实 Host 重启仍是独立生命周期和投影测试之外的集成覆盖缺口。
## Consequences
@@ -111,7 +111,6 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会
- 「慢慢回答」只影响一个 Client。其他 Client 可以回答或让共享 waterfall 到期,本地聚焦不构成全局暂停。
- 接手 stream 的终止性失败可能让前台问题失败,而不是产生 pending。不为该失败提供无人接手等待的 fallback。
- Host 与 Client 时钟不必对齐,但 Client 系统时钟变化可能移动到期时间,传输延迟可能推迟本地倒计时的开始。
- 重复投影仍保留在存储中。聚合在结构性分组重建时增加线性扫描;普通纯文本 stream 更新保留增量路径。这是算法复杂度界限,不是实测延迟结论。
- 无人回答的已继续问题可以在重新打开 Session 后继续存在。关闭面板不写记录,可见性遵循普通折叠,而不是保证回复始终可见。
- 排队的迟到回复可能在 agent 准入前被取消。只有已准入的 `user/message` 记录答案,问题在此之前保持可回答。
- PTC 子调用若在记录 `tool/ptc-dispatch` 结果前中断,无法从父 `run_code` 的失败中重建,因此恢复后不能接受迟到回答。
@@ -9,17 +9,17 @@
en: 84858bc0e1e268ab
zh: 39ebb2979968d6cc
/agent-note-chat-group-tree-positions-and-exchanges/decision:
en: c8c4091863dec269
zh: 57a0f26683867350
en: ca3153ae96994baa
zh: de6c7d925559efbb
/agent-note-chat-group-tree-positions-and-exchanges/why-mark-the-opening-rather-than-the-notice:
en: db8d77cd773ad646
zh: ab2f03b8fc58e952
/agent-note-chat-group-tree-positions-and-exchanges/alternatives-considered:
en: 635ddcb04fefe8e2
zh: 360c171ccb613ed5
en: a2b48738666a7d5f
zh: 53377f4f1109af26
/agent-note-chat-group-tree-positions-and-exchanges/consequences:
en: c8cfdc3009b19b12
zh: 36c541cd9692057d
en: f51678f9f30bad59
zh: b525e9ed10b9928c
/agent-note-chat-group-tree-positions-and-exchanges/testing:
en: 0d7f5281e66b0c39
zh: 5be221de46e9e0f7
en: e2e3982718e16ef4
zh: 4f3802946e51c55c
@@ -12,8 +12,9 @@ On next message folding kept only the last Turn of the loaded timeline expanded.
A notice is neither body text nor a user message: folding and bottom advancement ignore it. The bottom changes from the last Turn to the latest exchange.
- **Group-tree positions.** `ChatConversationViewNode.groupTree` declares a Node's position in the group tree, typed `ChatGroupTree = 'exchange' | 'turn' | 'process'` and ordered outermost first; a Node at a position also stands outside every inner grouping. `isAtOrOutside(node, position)` is the single reader of that order. Only `exchange` is read today; `turn` and `process` are reserved for migrating the `TURN_PROCESS_INDEPENDENT_KINDS` and process-group `INDEPENDENT` kind lists later without changing what `exchange` means.
- **Exchanges.** A Turn holding an `exchange` Node opens an exchange, which lasts until the next opening Turn. The business message Definition marks only human input claimed in Step 1 (`user` or `steering`) as `exchange`; notices and later steering carry no position.
- **Group-tree positions.** `ChatConversationViewNode.groupTree` declares a Node's position in the group tree, typed `ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process'` and ordered outermost first; a Node at a position also has the effect of every inner position. `exchange` opens an exchange. `input` makes the Node an input of its Turn: before the Turn's process it is the opening input, after it an interleaved input that keeps the whole Turn open. `turn` keeps the Node outside the Turn's process disclosure, `process` keeps it inside the disclosure but outside step runs, and a Node without a position joins the surrounding step run. `isAtOrOutside(node, position)` is the single reader of that order: process grouping reads `process`, whole-Turn folding reads `turn`, Turn-input detection reads `input`, and exchanges read `exchange`.
- **Exchanges.** A Turn holding an `exchange` Node opens an exchange, which lasts until the next opening Turn. The business message Definition marks only human input claimed in Step 1 (`user` or `steering`) as `exchange`; later human input and trigger notices stand at `input` and never open an exchange.
- **Built-in positions.** `turn-error`, `turn-max-tokens`, `turn-tail`, and the `turn-process` control stand at `turn`; `model-retry` stands at `process`. Two placement rules stay with ui-chat's own `assistant-step` Node: its reasoning row joins the step run while its reply row stands outside it, and the Turn's process specification keeps the finalized answer's reply outside the disclosure because which step answers changes as later steps arrive.
- **Mechanism.** `GroupTreeSegments(position)` splits the loaded Turns at the Turns holding a Node at that position. The latest segment starts at the last opening Turn, or at the first loaded Turn when the window holds none. It counts per Turn incrementally without scanning loaded Nodes, and a start move returns only the Turns that entered or left the latest segment. The Chat Builder's `bottomSource` is "the Node is visible and belongs to the latest exchange".
As a result, a notice-woken Turn no longer folds earlier Turns, so no room is reserved; the human's next ordinary message folds the whole exchange through the own-input path.
@@ -34,11 +35,14 @@ The host claims the waking input right after `turn/start`, assembles the system
**Correct the scroll room for folds not caused by own input.** Notices should not fold at all, and correcting the room would not remove that unnecessary fold; it remains a residual issue for human input from another tab.
**Migrate both kind lists now.** That reaches well beyond this defect; the type reserves the positions and the migration is separate work.
**Read Turn inputs at `turn`.** Terminal errors, max-token notices, and the completed-turn footer also stand outside the process disclosure, so reading `turn` would treat them as opening input; Turn inputs need their own position.
**Derive the exchange from an `input` in Step 1.** A trigger notice is its Turn's opening input but must not open an exchange. Deriving the exchange would drop trigger notices from `input` and change how notice-woken Turns order their process rows.
## Consequences
- Notice-woken Turns no longer cause a fold, reserved room, or blank space, and the grouping mechanism reads declared positions instead of naming kinds.
- Notice-woken Turns no longer cause a fold, reserved room, or blank space.
- External Definitions place their Chat rows through `groupTree`. Apart from the two `assistant-step` rules, ui-chat names no kind when it groups, folds, or detects Turn inputs, and built-in kinds keep the placement their former kind lists gave them.
- Under On next message, a long run of notice-woken Turns (up to 31 goal-mode Turns in sampled Sessions) stays expanded until the human types again.
- When the loaded window holds no opening Turn, every loaded Turn belongs to the latest exchange.
- Human input from another tab folds when its `user/message` arrives, about 200 ms later than the former `turn/start` fold; that fold still uses generic room and is unchanged here.
@@ -47,5 +51,6 @@ The host claims the waking input right after `turn/start`, assembles the system
- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`: position order, segmentation, count fallback, and the empty window.
- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`: latest-exchange membership and notification scope.
- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`: a real assembler across an opening Turn, mid-Turn steering, the pre-Step gap, a notice wake, and an idle steer.
- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`: a real assembler across an opening Turn, mid-Turn steering, the pre-Step gap, a notice wake, and an idle steer, with non-opening inputs at `input`.
- `packages/client/ui-chat/tests/process-groups.client.spec.ts`: a Node at any position splits the step run regardless of its kind, and a position change regroups.
- `apps/web/tests/notice-wake-tail.e2e.ts`: after a long Turn a notice wakes the next Turn; the long Turn stays expanded through the pre-Step gap and after the notice reply, and a following reader sees the notice and the reply.
@@ -12,8 +12,9 @@ Status: implemented
通知不算正文,也不算用户消息:折叠和推进底部时都忽略它。底部从"最后一个 Turn"改为"最新交互段"。
- **分组树位置。** `ChatConversationViewNode.groupTree` 声明节点在分组树中的位置,类型为 `ChatGroupTree = 'exchange' | 'turn' | 'process'`,由外到内有序;站在某个位置的节点同时也站在所有内层分组之外。`isAtOrOutside(node, position)` 是读取这一顺序的唯一入口。目前只读取 `exchange`;`turn` 和 `process` 预留给以后把 `TURN_PROCESS_INDEPENDENT_KINDS` 与过程组 `INDEPENDENT` 两份 kind 名单迁移过来,届时不改变 `exchange` 的含义。
- **交互段。** 含 `exchange` 节点的 Turn 开启一个交互段,交互段持续到下一个开启 Turn。业务层的 message Definition 只给 Step 1 中被认领的真人输入(`user` 或 `steering`)标 `exchange`;通知和之后的 steering 不标。
- **分组树位置。** `ChatConversationViewNode.groupTree` 声明节点在分组树中的位置,类型为 `ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process'`,由外到内有序;站在某个位置的节点同时具有所有内层位置的效果。`exchange` 开启一个交互段。`input` 表示节点是所在 Turn 的输入:出现在该轮过程之前即为开场输入,之后出现即为中途输入,使整个 Turn 保持展开。`turn` 让节点留在 Turn 的整轮折叠之外,`process` 让节点留在整轮折叠之内、步骤组之外,没有位置的节点并入相邻的步骤组。`isAtOrOutside(node, position)` 是读取这一顺序的唯一入口:过程分组读 `process`,整轮折叠读 `turn`,轮次输入判断读 `input`,交互段读 `exchange`。
- **交互段。** 含 `exchange` 节点的 Turn 开启一个交互段,交互段持续到下一个开启 Turn。业务层的 message Definition 只给 Step 1 中被认领的真人输入(`user` 或 `steering`)标 `exchange`;之后的真人输入和触发通知位于 `input`,不开启交互段。
- **内置位置。** `turn-error`、`turn-max-tokens`、`turn-tail` 与 `turn-process` 控件位于 `turn`;`model-retry` 位于 `process`。ui-chat 自己的 `assistant-step` 节点保留两条放置规则:推理行并入步骤组、回复行在步骤组之外;该轮的过程规格让最终答案的回复留在整轮折叠之外,因为哪一步是最终答案会随后续步骤到来而变化。
- **机制。** `GroupTreeSegments(position)` 按含该位置节点的 Turn 切分已加载的 Turn,最新一段从最后一个开启 Turn 开始,窗口中没有开启 Turn 时从第一个已加载 Turn 开始。它按 Turn 增量计数,不扫描已加载节点;起点移动时只返回进入或离开最新一段的 Turn。Chat Builder 的 `bottomSource` 为"节点可见且属于最新交互段"。
结果是:通知唤醒的 Turn 不再折叠前面的 Turn,没有折叠就没有补白;真人下一次普通输入时,整个交互段经由自己输入的路径一起折叠。
@@ -34,11 +35,14 @@ Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示
**修正非自身输入折叠时的滚动补白。** 通知本来就不应触发折叠,修补白不能消除这次不必要的折叠;它仍是另一个标签页里真人输入的残留问题。
**这次就迁移两份 kind 名单。** 改动面明显超出这次缺陷;类型先预留位置,迁移另做。
**在 `turn` 位置上判断轮次输入。** 终止错误、最大 token 提示和已完成轮次页脚同样在整轮折叠之外,读 `turn` 会把它们误当作开场输入;轮次输入需要自己的位置。
**由 Step 1 中的 `input` 推导交互段。** 触发通知是所在 Turn 的开场输入,却不能开启交互段。推导交互段就得把触发通知移出 `input`,从而改变通知唤醒的 Turn 中过程行的排列。
## Consequences
- 通知唤醒的 Turn 不再产生折叠、补白和空白;分组机制读取节点声明的位置,而不是点名 kind。
- 通知唤醒的 Turn 不再产生折叠、补白和空白。
- 外部 Definition 通过 `groupTree` 放置自己的 Chat 行。除 `assistant-step` 的两条规则外,ui-chat 在分组、折叠和判断轮次输入时不点名任何 kind,内置 kind 保持原 kind 名单给出的放置。
- 「下次发送时收起」下,一长串通知唤醒的 Turn(抽样会话中 Goal 模式最长 31 轮)会一直展开,直到真人再次输入。
- 已加载窗口里没有开启 Turn 时,所有已加载 Turn 都属于最新交互段。
- 另一个标签页里的真人输入在它的 `user/message` 到达时才折叠,比原来在 `turn/start` 时晚约 200 ms;这类折叠仍使用通用补白,本次没有修改。
@@ -47,5 +51,6 @@ Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示
- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`:位置顺序、切分、计数回退与空窗口。
- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`:最新交互段的成员与通知范围。
- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`:真实 assembler 下的开轮、中途 steering、Step 1 前的间隔、通知唤醒与空闲 steer。
- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`:真实 assembler 下的开轮、中途 steering、Step 1 前的间隔、通知唤醒与空闲 steer,非开启输入位于 `input`。
- `packages/client/ui-chat/tests/process-groups.client.spec.ts`:任意位置上的节点不论 kind 都会切开步骤组,位置变化会触发重新分组。
- `apps/web/tests/notice-wake-tail.e2e.ts`:长 Turn 之后由通知唤醒,在 Step 1 前的间隔和通知回复之后,上一轮都保持展开,跟尾读者看到通知与回复。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-10-12-exclusive-conversation-matches.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.md
/agent-note-exclusive-conversation-matches:
en: 6f78605cc85cf7f2
zh: f73e26bb3f9d1896
/agent-note-exclusive-conversation-matches/problem:
en: 38ed7541d4cb7019
zh: 98ed08f8c0c7c5ba
/agent-note-exclusive-conversation-matches/decision:
en: f2bfa3d0cc6b429a
zh: c1dd501c79086d71
/agent-note-exclusive-conversation-matches/alternatives-considered:
en: efd209a2c0e97381
zh: 8b6f10ff4de49449
/agent-note-exclusive-conversation-matches/consequences:
en: 414ae7349bd4cc03
zh: 9861b3e72bdfc548
/agent-note-exclusive-conversation-matches/testing:
en: a88ed18065da4d28
zh: 5bf4448a8b1f4763
@@ -0,0 +1,52 @@
# Agent Note: Exclusive Conversation matches
Status: implemented
English | [中文](2026-10-12-exclusive-conversation-matches.zh.md)
## Problem
Every event Definition matches each event independently, and a target may hold a generic Definition that presents a whole event family. When a business Definition presents one kind of that family itself, both Definitions match the same event and each builds a Context and a Node. Nothing let the business Definition say that the event belongs to it on that target.
The late question reply is the shipped case. Chat's generic `input-message` Definition matches every appended `user/message`, and the questions UI's `question-reply` Definition matches the reply message it presents. Chat process grouping therefore dropped the duplicate `turn-trigger` presentation by message id, in both the Turn-owned and unscoped paths, and anchored the Turn control to the first group member so it stayed above the reply. The duplicate Node remained in the Node Store, and the generic Definition kept treating the reply as context or a trigger notice.
## Decision
A match result may claim its event on the Definition's target:
```ts
interface ConversationMatchResult {
readonly id: string
readonly role: 'start' | 'update'
readonly exclusive?: true
}
```
- **Dispatch.** `ConversationNodeAssembler.dispatchInput()` evaluates every match of one event into two reused arrays before accepting any, then settles claims. Other Definitions of a claimed target build no Context for the event, and that target's unmatched-event fallback does not run. Definitions of other targets and Definitions without a target still receive it. The outcome is independent of registration order. Live append and window collection share this dispatch.
- **Failures.** Two claims on one target, or a claim from a Definition without a target, throw during dispatch and name the event, its `seq`, and the claiming Definition kinds.
- **Scope.** A claim belongs to one event and comes from `match()`, so a Definition claims only the events it recognizes. The generic Definition declares nothing.
- **Current use.** `question-reply` returns `exclusive: true` for a recorded `user-question-reply` message. Chat no longer aggregates by message id, and the reply's placement is unchanged.
## Alternatives considered
**Definition precedence (`specific` / `default`).** A generic Definition would declare itself overridable, and dispatch would become a priority order rather than a statement of which Definition presents an event. A claim needs no change to the generic Definition, and two claims fail instead of silently ranking.
**Declare human sources in a registry and render the reply through user-text rules.** The reply would stay a generic message Node with customized text. Chat's message and Inbox Definitions would need a service-backed predicate, registry changes would have to reassemble every bound Conversation, and the text rule would need the message source as a fact. A claim keeps the reply in its owning Definition.
**Mark human-authored sources in durable data.** A generic field on the message source would let the generic Definition classify the reply, but it changes a persisted type, and replies recorded by released builds do not carry it.
**Skip the reply kind in `input-message`.** The generic Chat Definition would name a plugin's source kind.
**Deduplicate in the grouping output.** This is the replaced mechanism: the duplicate Node stays stored and visible to every other Chat reader, and each grouping path repeats the aggregation.
## Consequences
- A claimed event has exactly one Context per claimed target, so no hidden duplicate remains in the Node Store.
- A conflicting claim fails at dispatch rather than at registration, because claims are decided per event from event data.
- Dispatch makes one extra pass over an event's matches, usually one to three, and allocates nothing for events without a claim.
- A plugin that presents part of a generic Definition's event family on one target uses a claim; it does not need the generic Definition's cooperation.
## Testing
- `packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts` ("exclusive matches"): a claim withholds the event from other Definitions of its target in either registration order and on live append; other targets, target-less Definitions, and no fallback; two claims and a target-less claim throw.
- `packages/client/ui-user-questions/tests/question-reply.client.spec.ts`: the reply match claims its event, and an assembler holding both message Definitions produces one Chat Node for the reply.
@@ -0,0 +1,52 @@
# Agent Note: Conversation 独占匹配
Status: implemented
[English](2026-10-12-exclusive-conversation-matches.md) | 中文
## Problem
每个事件 Definition 各自独立地匹配每个事件,同一个 target 上可能有一个呈现整类事件的通用 Definition。业务 Definition 自己呈现这类事件中的一种时,两个 Definition 会命中同一个事件,各自建立 Context 和 Node。业务 Definition 没有办法声明这个事件在该 target 上归它呈现。
迟到的问题回答是已上线的实例。Chat 的通用 `input-message` Definition 匹配每条追加的 `user/message`,问题 UI 的 `question-reply` Definition 也匹配它要呈现的回答消息。Chat 过程分组因此在有轮次归属和无轮次归属两条路径上都按消息 id 丢掉重复的 `turn-trigger` 呈现,并把整轮控件锚到组首成员,让它留在回答之上。重复的 Node 仍留在 Node Store 中,通用 Definition 也仍把回答当作 context 或触发通知。
## Decision
match 结果可以在 Definition 的 target 上认领它的事件:
```ts
interface ConversationMatchResult {
readonly id: string
readonly role: 'start' | 'update'
readonly exclusive?: true
}
```
- **派发。** `ConversationNodeAssembler.dispatchInput()` 先把一个事件的全部 match 求入两个复用数组,再结算认领,之后才逐个接收。被认领 target 上的其他 Definition 不为该事件建立 Context,该 target 的未匹配事件 fallback 也不运行;其他 target 的 Definition 与没有 target 的 Definition 照常接收。结果与注册顺序无关。实时追加和窗口收集共用这一派发。
- **失败。** 同一 target 被认领两次,或没有 target 的 Definition 发起认领,都会在派发时抛错,错误信息包含事件、其 `seq` 与认领方 Definition 的 kind。
- **范围。** 认领只对一个事件生效,由 `match()` 返回,因此 Definition 只认领它识别出的事件;通用 Definition 不需要声明任何东西。
- **当前用法。** `question-reply` 对已记录的 `user-question-reply` 消息返回 `exclusive: true`。Chat 不再按消息 id 聚合,回答的放置不变。
## Alternatives considered
**Definition 优先级(`specific` / `default`)。** 通用 Definition 得声明自己可被覆盖,派发也变成优先级排序,而不是声明由哪个 Definition 呈现事件。认领不要求改动通用 Definition,两个认领冲突时直接失败,而不是静默排序。
**用注册表声明真人来源,并通过用户文本规则渲染回答。** 回答会保持为通用消息 Node,只定制文本。Chat 的 message 与 Inbox Definition 需要一个依赖 service 的判断函数,注册表变化必须重新组装所有已绑定的 Conversation,文本规则还要把消息来源当作 fact 读取。认领让回答留在它自己的 Definition 中。
**在持久数据中标记真人撰写的来源。** 消息来源上的通用字段可以让通用 Definition 自行分类回答,但这会改动持久化类型,已发布版本记录的回答也不带这个字段。
**在 `input-message` 中跳过回答的 kind。** 通用的 Chat Definition 会点名插件的来源 kind。
**在分组输出层去重。** 这是被替换的机制:重复 Node 仍留在存储里,对其他所有 Chat 读取方可见,每条分组路径都要重复这一聚合。
## Consequences
- 被认领的事件在每个被认领 target 上恰好只有一个 Context,Node Store 中不再残留隐藏的重复节点。
- 认领冲突在派发时失败,而不是在注册时失败,因为认领由事件数据逐个决定。
- 派发对每个事件的 match 多遍历一次,通常一到三个;没有认领的事件不产生额外分配。
- 插件在一个 target 上呈现通用 Definition 事件族中的一部分时,使用认领即可,不需要通用 Definition 配合。
## Testing
- `packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts`("exclusive matches"):两种注册顺序与实时追加下,认领都会对同 target 的其他 Definition 屏蔽该事件;其他 target 与无 target 的 Definition 照常接收,fallback 不运行;两次认领与无 target 认领会抛错。
- `packages/client/ui-user-questions/tests/question-reply.client.spec.ts`:回答的 match 认领其事件;同时持有两个消息 Definition 的 assembler 只为回答产生一个 Chat Node。
+4
View File
@@ -54,6 +54,10 @@ Every menu, popover, and tooltip must be verified for all three before merge:
Scrollbars stay inside their container: use the shared scrollbar styles rather than component-specific selectors, and verify the bar neither overflows the container nor gets clipped by its rounded corners or edges — keep a small inset instead of letting it sit flush against the container edge.
## Responsive layouts
Use [dsh-responsive-ui](../dsh-responsive-ui/SKILL.md) for responsive layouts, text truncation, title wrapping, icon-only controls, and focus during width changes.
## Platform window adaptation
- Any new header element or full page must account for macOS and Windows window chrome. On macOS the titlebar clearance is `--dsh-frame-top-clearance`, consumed by Menu, Modal, and overlay primitives as their top safe margin — **never change the global variable to fix one page**. Add a scoped `[data-platform='darwin']` override on the page's own inset instead, and assert the override in the page's stylesheet spec.
+54
View File
@@ -0,0 +1,54 @@
---
name: dsh-responsive-ui
description: Use when designing, implementing, or reviewing responsive client layouts, text truncation, title wrapping, icon-only controls, mobile hover, tooltips and keyboard behavior, and focus during width changes in DeepSeek Harness.
---
# Responsive client UI
## 1. Minimum viewport
Use a viewport width of 390 CSS px as the primary acceptance minimum. There must be no unintended overlap, clipping, page-wide horizontal scrolling, or inaccessible primary actions. Below 390px, layout requirements may be relaxed.
## 2. Decision order
1. First adjust space allocation or wrap elements onto another line; then consider text truncation, icon-only controls, or moving icons into the More menu.
2. Primary actions and primary states must not be omitted.
3. Do not hide conflicts through global clipping, reducing all font sizes, or shrinking click targets.
4. Reduce font sizes only when specifically required by product design.
## 3. Text truncation
1. Truncated content must be fully available on a secondary page or by opening a menu. Example: Session statistics below the input box.
2. Truncation must not lose errors or other key information.
3. Prefer single-line truncation for tab navigation, visible selectors, buttons, tags, and similar components.
4. Important information that is not available on another page must be displayed in full without truncation.
## 4. Titles
1. Prefer displaying page titles in full. Move the action area onto another line when necessary. Example: the Plugins page's title wrapping behavior.
2. Prefer natural wrapping for card titles. If the number of lines is limited, the full title must remain available to view.
## 5. Icon-only controls
1. Reducing a control to an icon requires both:
- The icon clearly conveys its meaning, or the operation is familiar to users.
- A tooltip appears on hover; add one if needed.
2. Do not reduce “More account information” to an icon, because it navigates to a separate page.
3. The model selector in the input box can be reduced to an icon because users see it frequently and can open its menu to switch models.
4. Another example is the linked conversation in an automation task.
## 6. Mobile hover behavior
1. Enable mouse-hover effects only on devices that support hovering. Use `@media (hover: hover)`, not viewport width, to determine this.
2. Tapping on a phone must not leave behind backgrounds, highlights, or action-button visibility intended only for hover.
3. Preserve normal selected, enabled, and pressed states, as well as focus indicators for keyboard interaction.
4. Try to preserve focus when the width changes, unless the focused element disappears from the page as a result.
## 7. Mobile tooltips
1. Do not show tooltips that only provide auxiliary information such as action names or keyboard shortcuts on mobile. For example, omit the “Copy” tooltip on a copy button; tapping still performs the original action directly.
2. Tooltips that provide supplementary explanations must support tap-to-show on mobile. For example, the information tooltip beside the Plugins page description.
## 8. Mobile keyboard
On mobile, do not raise the input keyboard automatically unless it is explicitly intended.
@@ -0,0 +1,18 @@
# Plugin header responsive layout
en 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
en 768px: actions below the heading, single-line; contained; information follows subtitle; focus retained
en 520px: actions below the heading, single-line; contained; information follows subtitle; focus retained
en 440px: actions below the heading, single-line; contained; information follows subtitle; focus retained
en 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained
en 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained
en 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
zh 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
zh 768px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
zh 520px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
zh 440px: actions below the heading, single-line; contained; information follows subtitle; focus retained
zh 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained
zh 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained
zh 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
Add menu dismisses on Escape and returns focus in both locales.
@@ -0,0 +1,17 @@
# Automation tasks header
en 1680px: title and New single-line, New beside the title; contained; New retains focus
en 800px: title and New single-line, New beside the title; contained; New retains focus
en 520px: title and New single-line, New beside the title; contained; New retains focus
en 440px: title and New single-line, New beside the title; contained; New retains focus
en 390px: title and New single-line, New beside the title; contained; New retains focus
en 430px: title and New single-line, New beside the title; contained; New retains focus
en 1024px: title and New single-line, New beside the title; contained; New retains focus
en 509px with sidebar expanded: title single-line; New moves below the title
zh 1680px: title and New single-line, New beside the title; contained; New retains focus
zh 800px: title and New single-line, New beside the title; contained; New retains focus
zh 520px: title and New single-line, New beside the title; contained; New retains focus
zh 440px: title and New single-line, New beside the title; contained; New retains focus
zh 390px: title and New single-line, New beside the title; contained; New retains focus
zh 430px: title and New single-line, New beside the title; contained; New retains focus
zh 1024px: title and New single-line, New beside the title; contained; New retains focus
+97 -1
View File
@@ -96,6 +96,102 @@ describe('web e2e: plugin manager', () => {
return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '')
}
it('keeps plugin header actions on one line as the available width changes', async () => {
const facts: string[] = []
// A dedicated scaffold keeps the measured header deterministic: an install
// task left by an earlier test swaps the toolbar's add control for the
// view-installation button, which moves the wrap threshold.
const headerScaffold = await launchWebScaffold()
try {
// The widths where the header must have wrapped or not are pinned; the
// remaining widths assert only that the action group is placed on one side
// of the heading, and the golden records which.
const PINNED_PLACEMENT: Record<number, 'beside' | 'below'> = { 1680: 'beside', 1024: 'beside', 390: 'below', 430: 'below' }
for (const language of ['en', 'zh'] as const) {
const context = await browser.newContext({
viewport: { width: 1680, height: 1000 }, locale: language === 'en' ? 'en-US' : ZH_BROWSER_LOCALE,
reducedMotion: 'reduce',
})
try {
const probe = await context.newPage()
const consoleWatch = watchConsole(probe)
await probe.goto(headerScaffold.authenticatedUrl, { waitUntil: 'load' })
const title = language === 'en' ? 'Plugins' : '插件'
await probe.getByRole('button', { name: title, exact: true }).click()
const panel = probe.locator('[data-plugin-panel]')
const header = panel.locator(':scope > header')
await header.getByRole('heading', { name: title, exact: true }).waitFor()
await probe.evaluate(() => document.fonts.ready)
const add = header.getByRole('button', { name: language === 'en' ? 'Add plugin' : '添加插件', exact: true })
await expect.poll(() => add.isEnabled()).toBe(true)
await add.focus()
for (const width of [1680, 768, 520, 440, 390, 430, 1024]) {
await probe.setViewportSize({ width, height: 1000 })
const measure = () => header.evaluate((element) => {
const heading = element.firstElementChild!
const actions = element.lastElementChild!
const headingRect = heading.getBoundingClientRect()
const actionRect = actions.getBoundingClientRect()
const headerRect = element.getBoundingClientRect()
const subtitle = heading.querySelector('h1 + div > span')!
const range = document.createRange()
range.selectNodeContents(subtitle)
const textRects = Array.from(range.getClientRects())
const lastText = textRects.at(-1)!
const info = heading.querySelector('button')!.getBoundingClientRect()
const buttons = Array.from(actions.querySelectorAll('button'))
const singleLine = buttons.every((button) => {
const walker = document.createTreeWalker(button, NodeFilter.SHOW_TEXT)
const tops: number[] = []
while (walker.nextNode()) {
if (!walker.currentNode.textContent?.trim()) continue
range.selectNodeContents(walker.currentNode)
for (const rect of range.getClientRects()) tops.push(rect.top)
}
return tops.length === 0 || Math.max(...tops) - Math.min(...tops) < 1
})
return {
singleLine,
placement: actionRect.left >= headingRect.right - 1
? 'beside' as const
: actionRect.top >= headingRect.bottom - 1 ? 'below' as const : 'overlapping' as const,
contained: actionRect.left >= headerRect.left - 1 && actionRect.right <= headerRect.right + 1,
infoFollowsText: info.left >= lastText.right && info.left - lastText.right <= 5
&& info.top < lastText.bottom && info.bottom > lastText.top,
focused: buttons.includes(document.activeElement as HTMLButtonElement),
}
})
const pinned = PINNED_PLACEMENT[width]
await expect.poll(measure).toMatchObject({
singleLine: true, contained: true, infoFollowsText: true, focused: true,
})
const placed = (await measure()).placement
if (pinned === undefined) expect(['beside', 'below']).toContain(placed)
else expect(placed).toBe(pinned)
facts.push(`${language} ${width}px: actions ${placed} the heading, single-line; contained; information follows subtitle; focus retained`)
}
await probe.setViewportSize({ width: 390, height: 1000 })
const menuTrigger = header.getByRole('button', { name: language === 'en' ? 'Choose how to add a plugin' : '选择添加插件方式' })
await menuTrigger.click()
await probe.getByRole('menu').waitFor()
await probe.keyboard.press('Escape')
await expect.poll(() => probe.getByRole('menu').count()).toBe(0)
expect(await menuTrigger.evaluate(element => element === document.activeElement)).toBe(true)
expect(consoleWatch.pageErrors).toEqual([])
} finally {
await context.close()
}
}
await compareOrRefreshGolden(
join(SNAPSHOT_DIR, 'header-responsive.expected.md'),
`# Plugin header responsive layout\n\n${facts.join('\n')}\n\nAdd menu dismisses on Escape and returns focus in both locales.`,
MODE,
)
} finally {
await headerScaffold.close()
}
})
it('aligns the first-read skeleton with the loaded plugin cards', async () => {
const facts: string[] = []
let aria = ''
@@ -932,7 +1028,7 @@ describe('web e2e: plugin manager', () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', 'refresh.expected.md',
'ime-enter.expected.md',
'ime-enter.expected.md', 'header-responsive.expected.md',
])
})
})
+97
View File
@@ -0,0 +1,97 @@
// UI-only geometry for the Automation tasks page; no model calls or task mutations.
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
import { expect, it } from 'vitest'
import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode } from './scaffold.ts'
it('keeps the automation title intact before moving New to another line', async () => {
const scaffold = await launchWebScaffold()
try {
const browser = await chromium.launch()
try {
const facts: string[] = []
for (const language of ['en', 'zh'] as const) {
const context = await browser.newContext({
viewport: { width: 1680, height: 1000 },
locale: language === 'en' ? 'en-US' : 'zh-CN',
reducedMotion: 'reduce',
})
try {
const page = await context.newPage()
const consoleWatch = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
const title = language === 'en' ? 'Automation tasks' : '自动化任务'
await page.getByRole('button', { name: title, exact: true }).click()
const panel = page.getByTestId('task-manager-page')
const heading = panel.getByRole('heading', { name: title, exact: true })
await heading.waitFor()
const header = heading.locator('..')
const create = header.getByRole('button', { name: language === 'en' ? 'New' : '新建', exact: true })
await page.evaluate(() => document.fonts.ready)
await create.focus()
for (const width of [1680, 800, 520, 440, 390, 430, 1024]) {
await page.setViewportSize({ width, height: 1000 })
// The title keeps its natural width at every viewport width, so New
// stays beside it here; the squeeze case below is what moves it down.
await expect.poll(() => header.evaluate((element) => {
const title = element.querySelector('h1')!
const button = element.querySelector('button')!
const titleRect = title.getBoundingClientRect()
const buttonRect = button.getBoundingClientRect()
const headerRect = element.getBoundingClientRect()
const range = document.createRange()
range.selectNodeContents(title)
const titleLines = Array.from(range.getClientRects())
return {
titleSingleLine: titleLines.length === 1,
buttonSingleLine: button.scrollHeight <= button.clientHeight,
placement: buttonRect.left >= titleRect.right - 1
? 'beside' as const
: buttonRect.top >= titleRect.bottom - 1 ? 'below' as const : 'overlapping' as const,
contained: titleRect.left >= headerRect.left - 1 && titleRect.right <= headerRect.right + 1
&& buttonRect.left >= headerRect.left - 1 && buttonRect.right <= headerRect.right + 1,
focused: document.activeElement === button,
}
})).toEqual({
titleSingleLine: true, buttonSingleLine: true, placement: 'beside', contained: true, focused: true,
})
facts.push(`${language} ${width}px: title and New single-line, New beside the title; contained; New retains focus`)
}
if (language === 'en') {
// 509px collapses the left sidebar to its rail; expanding it leaves the
// page column narrow enough (measured 172px) to force the wrap.
await page.setViewportSize({ width: 509, height: 1000 })
await page.getByRole('button', { name: 'Open sidebar', exact: true }).first().click()
await expect.poll(() => header.evaluate((element) => {
const title = element.querySelector('h1')!
const button = element.querySelector('button')!
const titleRect = title.getBoundingClientRect()
const buttonRect = button.getBoundingClientRect()
const range = document.createRange()
range.selectNodeContents(title)
return {
titleSingleLine: range.getClientRects().length === 1,
placement: buttonRect.left >= titleRect.right - 1
? 'beside' as const
: buttonRect.top >= titleRect.bottom - 1 ? 'below' as const : 'overlapping' as const,
}
})).toEqual({ titleSingleLine: true, placement: 'below' })
facts.push('en 509px with sidebar expanded: title single-line; New moves below the title')
}
expect(consoleWatch.pageErrors).toEqual([])
} finally {
await context.close()
}
}
await compareOrRefreshGolden(
fileURLToPath(new URL('./expected/schedule-header.expected.md', import.meta.url)),
`# Automation tasks header\n\n${facts.join('\n')}`,
webSnapshotMode(),
)
} finally {
await browser.close()
}
} finally {
await scaffold.close()
}
})
+1
View File
@@ -79,6 +79,7 @@
"tests/plugin-config.e2e.ts",
"tests/input-plugin-extensions.e2e.ts",
"tests/plugin-manager.e2e.ts",
"tests/schedule-header.e2e.ts",
"tests/worktree-opt-in.e2e.ts",
"tests/session-inspector.e2e.ts",
"tests/shared-inspector.e2e.ts",
+2 -2
View File
@@ -18,8 +18,8 @@
en: c216e2df128b8cb9
zh: c039cf84b0d86ad8
/conversation-assembly/definition-and-typed-chat-payload:
en: 5f24557a050d1df0
zh: 47735627f802b6fa
en: d8f5e4773704c718
zh: f3db8c0d01080dba
/conversation-assembly/predecessor-reads:
en: da849b3b5da81da8
zh: 37a647a439bc24b1
+14
View File
@@ -247,10 +247,24 @@ export function apply(ctx: ClientContext): void {
`ConversationNodeDefinitionInput` accepts either this function or a read-only table from event type to matching function. Function-form registrations receive every event; table registrations receive only their listed types. The Registry copies table entries and precomputes ordered candidate Sets when registrations change, keeping candidate construction out of per-event dispatch. Replace a table by unregistering and registering again. Resolved `ConversationNodeDefinition` values retain a callable `match(event)` in both cases.
A match result with `exclusive: true` claims that event on the Definition's target. The assembler evaluates every match of the event before accepting any, so the outcome is independent of registration order: other Definitions of the claimed target build no Context for the event, and the target's unmatched-event fallback does not run. Definitions of other targets and Definitions without a target still receive it. Two claims on one target, or a claim from a Definition without a target, throw during dispatch; a claim returned by the fallback itself has no effect. A business Definition uses a claim to present one kind of message that a generic Definition of the same target would otherwise also present, such as a late question reply.
`buildLocationData(context, scope)` optionally publishes Definition-owned data onto an engine-owned Turn or Step. Use declaration merging to give each key a precise value type. Another Node in the same Location can consume that value through its constrained slot hook, such as `useTurnData(key)`, without receiving the Session or scanning `snapshot.chat.nodes`.
`target` and `buildViewNode(context)` declare one target-owned rendering contribution and must appear together. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from the matched event ordering, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`.
A Chat Node also chooses where its row stands through `groupTree`. Positions are ordered from the outermost inward, and a Node at a position also has the effect of every inner one. Chat process grouping, whole-Turn folding, and Turn-input detection read only this position; [group-tree.ts](../../packages/client/ui-chat/src/client/contract/group-tree.ts) holds the complete contract.
| The Node is | Declare |
|---|---|
| A person's input claimed in Step 1; its Turn opens an exchange | `exchange` |
| Another input of its Turn, such as later human input or a trigger notice; before the process it is the opening input, after it the whole Turn stays open | `input` |
| A Turn notice that stays outside whole-Turn folding, such as an error or the completed-turn footer | `turn` |
| An independent row that whole-Turn folding still hides, such as a model retry | `process` |
| Ordinary process content that joins the surrounding step run | omit `groupTree` |
The [Chat business rules](../../packages/client/ui-chat/src/client/conversation-nodes/README.md#process-grouping) list the positions the built-in Nodes declare. When a generic Definition of the same target also matches the event your Node presents, claim the event with `exclusive: true` as described above.
## Predecessor reads
Some Definitions need the latest earlier State of another business kind. `start` receives a `ConversationContextReader`; call `reader.previous<State>(kind)` there instead of accepting a Context collection or scanning events. The reader returns the nearest started Context before the current start `seq` as read-only data.
+14
View File
@@ -249,10 +249,24 @@ export function apply(ctx: ClientContext): void {
`ConversationNodeDefinitionInput` 接受上述函数,或事件类型到匹配函数的只读表。函数形式的注册接收所有事件;表形式只接收列出的类型。Registry 在注册关系变化时复制表条目并预计算有序候选 Set,不在逐事件分发时构造候选集合。替换表时先注销,再重新注册。两种形式解析后的 `ConversationNodeDefinition` 都保留可调用的 `match(event)`。
match 结果带 `exclusive: true` 时,即在该 Definition 的 target 上认领这个事件。Assembler 先求出该事件的全部 match,再逐个接收,因此结果与注册顺序无关:被认领 target 上的其他 Definition 不为该事件建立 Context,该 target 的未匹配事件 fallback 也不运行;其他 target 的 Definition 与没有 target 的 Definition 照常接收。同一 target 被认领两次,或没有 target 的 Definition 发起认领,都会在分发时抛错;fallback 自身返回的认领不起作用。业务 Definition 用认领来呈现某一类消息,而同一 target 上的通用 Definition 原本也会呈现它,例如迟到的问题回答。
`buildLocationData(context, scope)` 可以把 Definition 拥有的数据发布到引擎拥有的 Turn 或 Step 上。通过 declaration merging 为每个 key 指定精确 value 类型。同一 Location 内的另一个 Node 可以使用受限 slot hook(例如 `useTurnData(key)`)读取该值,无须取得 Session,也无须扫描 `snapshot.chat.nodes`。
`target` 与 `buildViewNode(context)` 必须同时声明一项由 target 拥有的渲染贡献。把 `context.key` 保留为 React 侧身份,根据持久排序证据选择 `anchorSeq`,并且只返回 renderer 可以直接使用的数据。某个 target Node 一旦发布,就要继续返回同一个 key;需要暂时离开可见流时使用 `visibility: 'hidden'`,不要改为返回 `null` 撤回它。
Chat Node 还通过 `groupTree` 选择所在行的位置。位置由外到内排列,站在某个位置的 Node 同时具有所有内层位置的效果。Chat 的过程分组、整轮折叠和轮次输入判断只读取这个位置;完整契约见 [group-tree.ts](../../packages/client/ui-chat/src/client/contract/group-tree.ts)。
| Node 是 | 声明 |
|---|---|
| Step 1 认领的人的输入,所在轮次开启一个交互段 | `exchange` |
| 所在轮次的其他输入,如之后的人的输入或触发通知;位于过程之前时是开场输入,之后出现时整个轮次保持展开 | `input` |
| 留在整轮折叠之外的轮次通知,如错误或已完成轮次页脚 | `turn` |
| 整轮折叠仍会隐藏的独立行,如模型重试 | `process` |
| 并入相邻步骤组的普通过程内容 | 不声明 `groupTree` |
各内置 Node 声明的位置见 [Chat 业务规则](../../packages/client/ui-chat/src/client/conversation-nodes/README.zh.md#process-grouping)。如果同一 target 的通用 Definition 也会匹配你的 Node 所呈现的事件,按上文用 `exclusive: true` 认领该事件。
## Predecessor read
有些 Definition 需要另一个业务 kind 在当前位置之前的最新 State。`start` 会收到 `ConversationContextReader`;应在这里调用 `reader.previous<State>(kind)`,不要接收 Context 集合或扫描事件。Reader 返回当前 start `seq` 之前最近一个已启动 Context 的只读数据。
+2 -2
View File
@@ -33,8 +33,8 @@
en: 4cd7c81d70d2f365
zh: 18eca6d16e44b6c2
/deepseek-ai-dsh-client-ui-chat/grouped-rendering:
en: bc8d1d786c9affea
zh: 3139f0228527a174
en: 81d7ca7595c34d39
zh: 29eb8248c75e9ea6
/deepseek-ai-dsh-client-ui-chat/scroll-ownership:
en: 3f4e6c161b4583bb
zh: e4265bb2bce8eb1c
+1 -1
View File
@@ -103,7 +103,7 @@ Chat registers its process Group Definition through `uiConversation.groups`. Rea
`groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. A Tool node owns its preparing, dispatched, and result stages under one callId. 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 Chat Builder publishes `bottomSource` per Node: true for visible Nodes belonging to the latest loaded exchange, not just the final row. Nodes declare their group-tree position through `groupTree`; a Turn holding an `exchange` Node opens an exchange, and the exchange lasts until the next opening Turn. Moving the exchange start notifies only Nodes in Turns that entered or left it. Groups read their first member's position. Pending-input deduplication and collapse decisions remain in the Chat view, outside the Builder.
The Chat Builder publishes `bottomSource` per Node: true for visible Nodes belonging to the latest loaded exchange, not just the final row. Nodes declare their group-tree position through `groupTree`; a Turn holding an `exchange` Node opens an exchange, and the exchange lasts until the next opening Turn. The positions, from outermost inward `exchange`, `input`, `turn`, and `process`, are defined in [group-tree.ts](src/client/contract/group-tree.ts); process grouping, whole-Turn folding, and Turn-input detection read them, so an external Definition places its Chat rows by declaring one. Moving the exchange start notifies only Nodes in Turns that entered or left it. Groups read their first member's position. Pending-input deduplication and collapse decisions remain in the Chat view, outside the Builder.
Live tool deltas share reasoning's frame-batched publication; durable calls and results publish immediately. Native and nested results retain event presentation metadata, so historical file and command labels use the operation’s location. Repeated deltas retain the Tool node when observed argument answers, the anchor, location, and visibility are unchanged. The Definition supplies the same lazy argument reader to tool rows and group detail, without tool-specific registration.
+1 -1
View File
@@ -103,7 +103,7 @@ Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过
`groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。同一个 callId 的准备、派发与结果阶段由 Tool 节点自己拥有。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。
Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的最新交互段时为 true,而不只是列表最后一行。节点通过 `groupTree` 声明自己在分组树中的位置;含 `exchange` 节点的轮次开启一个交互段,交互段持续到下一个开启轮次。交互段起点移动时,只有进入或离开该交互段的轮次中的节点收到通知。Group 读取其首个成员的位置。待发送消息去重与收起判断仍由 Chat 视图处理,不进入 Builder。
Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的最新交互段时为 true,而不只是列表最后一行。节点通过 `groupTree` 声明自己在分组树中的位置;含 `exchange` 节点的轮次开启一个交互段,交互段持续到下一个开启轮次。这些位置由外到内依次为 `exchange`、`input`、`turn`、`process`,定义在 [group-tree.ts](src/client/contract/group-tree.ts);过程分组、整轮折叠和轮次输入判断都读取它们,外部 Definition 声明位置即可放置自己的 Chat 行。交互段起点移动时,只有进入或离开该交互段的轮次中的节点收到通知。Group 读取其首个成员的位置。待发送消息去重与收起判断仍由 Chat 视图处理,不进入 Builder。
实时工具 delta 与推理共用按帧合并的发布节奏;持久调用和结果立即发布。原生与嵌套结果保留事件中的展示元数据,使历史文件与命令标签使用执行时的位置。当已读取参数的答案、锚点、位置及可见性均未变化时,重复 delta 保持 Tool 节点不变。Definition 向工具行和分组详情提供同一个懒计算参数读器,无需按工具注册。
@@ -5,7 +5,8 @@ import type { ConversationLocationDataStore, ConversationTurnDataMap } from '@de
import type { ChatNodeHookContext, ChatNodeOwnerProps, ChatFlowSlotProps, 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, turnProcessAlwaysOpen } from '../contract/turn-process.ts'
import { isAtOrOutside } from '../contract/group-tree.ts'
import { turnProcessAlwaysOpen } from '../contract/turn-process.ts'
import { turnProcessOpen } from '../stores.ts'
import { hiddenOrCollapsing } from './flow-motion.ts'
import css from './ChatView.module.css'
@@ -76,7 +77,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
&& (processPresentation.turnStarted || processPresentation.turnClosed)
const processMember = routedNode !== undefined
&& processWindowReady
&& !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind)
&& !isAtOrOutside(routedNode, 'turn')
&& routedNode.anchorSeq >= processSpec.processStartSeq
&& (liveProcess || processSpec.answerAnchorSeq === null || routedNode.anchorSeq < processSpec.answerAnchorSeq
|| (groupPart === 'reasoning' && routedNode.kind === 'assistant-step' && routedNode.data.step === processSpec.answerStep))
@@ -10,6 +10,7 @@ import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/
import {
Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { isAtOrOutside } from '../contract/group-tree.ts'
import type { ChatFlowHookContext, ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts'
import type { ChatSnapshot } from '../contract/snapshot.ts'
import { TurnNavigator } from './TurnNavigator.tsx'
@@ -37,13 +38,12 @@ function observedInputs(
let lastInputTurn: number | undefined
for (const key of order) {
const node = nodes.get(key)
if (node === undefined || (node.kind !== 'user' && node.kind !== 'steering' && node.kind !== 'turn-trigger')) continue
if (node === undefined || !isAtOrOutside(node, 'input')) continue
if (node.location.kind === 'turn' || node.location.kind === 'step') lastInputTurn = node.location.turn.turn
if (node.kind === 'turn-trigger') continue
const source = (node.data as { readonly source?: unknown }).source as
| { readonly kind?: unknown; readonly rpcId?: unknown }
| undefined
if (source?.kind === 'user' && typeof source.rpcId === 'string') observed.add(source.rpcId)
const data: unknown = node.data
const source = typeof data === 'object' && data !== null && 'source' in data ? data.source : undefined
if (typeof source === 'object' && source !== null && 'kind' in source && source.kind === 'user'
&& 'rpcId' in source && typeof source.rpcId === 'string') observed.add(source.rpcId)
}
return { rpcIds: observed, lastInputTurn }
}
@@ -12,10 +12,10 @@ export interface ChatConversationViewNode extends ConversationViewNode {
readonly location: ConversationLocation
readonly visibility: 'visible' | 'hidden'
/**
* Group-tree position, visible or hidden. An `exchange` Node's Turn and every
* later Turn before the next opening Turn form one exchange; the latest
* loaded exchange starts at the last loaded opening Turn, or at the first
* loaded Turn when none is loaded.
* Group-tree position, visible or hidden; absence joins the surrounding step
* run. An `exchange` Node's Turn and every later Turn before the next opening
* Turn form one exchange; the latest loaded exchange starts at the last loaded
* opening Turn, or at the first loaded Turn when none is loaded.
*/
readonly groupTree?: ChatGroupTree | undefined
}
@@ -1,20 +1,30 @@
import type { ChatConversationViewNode } from './chat-nodes.ts'
/**
* Chat group-tree positions from the outermost grouping inward; a Node at a
* position also stands outside every inner grouping.
* Chat group-tree positions from the outermost inward; a Node at a position
* also has the effect of every inner position.
* - `exchange`: the Node's Turn opens an exchange, the run of Turns that lasts
* until the next opening Turn.
* - `input`: the Node is an input of its Turn. An input before the Turn's
* process is the Turn's opening input; a later one is interleaved and keeps
* the whole Turn open.
* - `turn`: the Node stays in its Turn outside the Turn's process disclosure.
* - `process`: the Node stays in the process disclosure outside step runs.
* - absent: the Node joins the step run around it.
*
* Only `exchange` is read today; process disclosure and step runs still select
* independent Nodes by kind (`TURN_PROCESS_INDEPENDENT_KINDS`, the process-group
* `INDEPENDENT` set), so no Definition publishes `turn` or `process` yet.
* The Definition that builds a Node chooses its position, and step runs, the
* process disclosure, and Turn inputs are selected by position. An
* `assistant-step` Node places its rows without one: its reasoning row joins
* the step run and its reply row stands outside it.
*
* Whole-Turn folding also exempts the finalized answer's reply. That exemption
* comes from the Turn's process specification (`answerAnchorSeq` and
* `answerStep`), not from a position, because which step answers changes as
* later steps arrive.
*/
export type ChatGroupTree = 'exchange' | 'turn' | 'process'
export type ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process'
const DEPTH: Readonly<Record<ChatGroupTree, number>> = { exchange: 0, turn: 1, process: 2 }
const DEPTH: Readonly<Record<ChatGroupTree, number>> = { exchange: 0, input: 1, turn: 2, process: 3 }
/**
* Test whether a Node stands at a group-tree position or outside it.
@@ -17,22 +17,6 @@ export interface TurnProcessSpec {
readonly subagentCount: number
}
const TURN_PROCESS_INDEPENDENT_KIND_LIST = [
'system-prompt',
'user',
'steering',
'turn-trigger',
'turn-process',
'turn-error',
'turn-max-tokens',
'turn-tail',
] as const satisfies readonly ChatNode['kind'][]
/** Chat Node kinds that remain independent of a Turn's process disclosure. */
export const TURN_PROCESS_INDEPENDENT_KINDS: ReadonlySet<string> = new Set(
TURN_PROCESS_INDEPENDENT_KIND_LIST,
)
/**
* Compare immutable Turn-process specifications by their published fields.
* @param left - previous specification.
@@ -24,8 +24,8 @@
en: 91b35cbebe8c2a1d
zh: a4fee6d7102bc874
/chat-conversation-node-rules/whole-turn-folding:
en: 8e0c0b62da18a24f
zh: f740809d0b7ff2f5
en: 42c6f6b9e2433263
zh: 651ceb5c22314259
/chat-conversation-node-rules/display-modes:
en: ada5a60d2c46f6a8
zh: f3eaeb5cb4d9b3de
@@ -39,8 +39,8 @@
en: 2392290662bc69e8
zh: 8872d651c07a8f4c
/chat-conversation-node-rules/process-grouping:
en: c2650489ded57503
zh: f2554d74e75ae0f9
en: 3d28b3bb8757bf55
zh: 3b0f451b48db6048
/chat-conversation-node-rules/process-grouping/examples:
en: 2a550f7e04bce4b0
zh: b8f95b687448cd77
@@ -94,7 +94,7 @@ Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs`
| 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.
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. A Node whose [group-tree position](../contract/group-tree.ts) is `turn` or outer remains independent of whole-Turn folding. Turn inputs stand at `input` or `exchange`: user and steering messages and trigger notices; an input after the process start keeps the whole Turn open. Terminal errors, max-token notices, the completed-turn footer, and the Turn control stand at `turn`. A `process` Node, such as a model retry, separates secondary groups but whole-Turn folding still includes it.
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.
@@ -198,15 +198,14 @@ Switching modes retains manually opened groups and inner disclosures. It changes
A group collects adjacent process content within one Turn. Step-number changes alone do not split it. All four modes use the same grouping result. A group's `closed` flag means its content segment has ended, not that its UI disclosure is collapsed.
When `question-reply` and `turn-trigger` project the same message id, grouping references only the question reply. Both Nodes stay in the Node Store; the duplicate trigger neither renders nor splits a group. The Turn control precedes the group containing an opening reply. This aggregation also applies before the owning Turn is loaded.
Grouping reads each Node's [group-tree position](../contract/group-tree.ts), which its Definition sets, rather than its kind.
| 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 at `process` or an outer position: `model-retry`, Turn inputs, `turn-error`, `turn-max-tokens`, `turn-tail`, and the `turn-process` control | End the preceding group and retain the Node as an independent root. |
| Other visible Nodes owned by a Turn without a position, 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. |
@@ -94,7 +94,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
| 起点与结束记录均未加载 | 保留过程行可见,不显示整轮控件。 |
| 存在整轮控件,但没有过程内容 | 保留标题,不提供折叠操作。 |
最终答案取最后一个 Step 中已结算的 Assistant 回复,要求有可见回复内容且没有工具调用块。最终答案的回复正文不参与整轮折叠,推理仍属于过程。用户与 steering 输入、触发通知、终止错误、最大 token 提示及已完成轮次页脚保持独立。二级分组把模型重试视为分隔节点,但整轮折叠仍包含重试行。
最终答案取最后一个 Step 中已结算的 Assistant 回复,要求有可见回复内容且没有工具调用块。最终答案的回复正文不参与整轮折叠,推理仍属于过程。[分组树位置](../contract/group-tree.ts)为 `turn` 或更外层的 Node 不参与整轮折叠。轮次输入位于 `input` 或 `exchange`:用户与 steering 消息、触发通知;过程开始后出现的输入使整个轮次保持展开。终止错误、最大 token 提示、已完成轮次页脚和整轮控件位于 `turn`。`process` 位置的 Node(如模型重试)分隔二级分组,但整轮折叠仍包含它。
加载更早的一页保留用户的组级开合选择。最终答案未变化且仍符合整轮折叠条件时,新加载的过程内容沿用该轮的开合状态;若新页揭示中途输入,则改用各组开合,不再整轮隐藏。真正的起点到达后才显示时长,是否已加载完全部历史不是额外折叠条件。若新内容只是接在旧组前面,旧组和旧消息行保留身份及已有展开状态。新页出现回复、steering 等真实边界时仍按规则分组,不把所有新增内容强并进旧组。
@@ -198,15 +198,14 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
一组收集同一轮次内相邻的过程内容,仅 Step 编号变化不会拆组。四档模式使用同一分组结果。组的 `closed` 表示这段内容已经结束,不表示 UI 开合控件处于收起状态。
当 `question-reply` 和 `turn-trigger` 投影自同一个消息 id 时,分组只引用问答回复。两个 Node 都保留在 Node Store 中;重复的触发节点既不显示,也不切开分组。整轮控件位于包含开场回复的分组之前。所属轮次尚未加载时也执行这一聚合。
分组读取每个 Node 由其 Definition 设定的[分组树位置](../contract/group-tree.ts),而不是它的 kind。
| 输入 | 归属与切分 |
|---|---|
| 非空白 Assistant 推理 | 将该 Assistant 的一个 `reasoning` 引用加入当前组,必要时创建组。 |
| Assistant 回复 | 结束前面的组,输出独立的 `response` 引用。同一 Node 含有推理时,先将推理加入组,再结束该组。 |
| `user`、`steering`、`turn-trigger`、`model-retry`、`turn-error`、`turn-max-tokens`、`turn-tail` | 结束前面的组,Node 作为独立根引用保留。 |
| `turn-process` | 控件作为独立根引用保留,不结束当前组。 |
| 轮次内其他可见 Node,包括工具 | 将整个 Node 加入当前组,必要时创建组。 |
| 位于 `process` 或更外层位置的 Node:`model-retry`、轮次输入、`turn-error`、`turn-max-tokens`、`turn-tail` 及 `turn-process` 控件 | 结束前面的组,Node 作为独立根引用保留。 |
| 轮次内没有位置的其他可见 Node,包括工具 | 将整个 Node 加入当前组,必要时创建组。 |
| 另一轮次或没有轮次归属的 Node | 截断同轮次序列。无轮次 Node 保持独立,Assistant 也不拆分。 |
| 隐藏 Node、系统提示词、普通 Context 注入及 `permission` 命令 | 不进入分组输入,既不入组,也不截断组。 |
@@ -11,7 +11,7 @@ import type {
ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore, ChatSnapshot,
ChatTurnNavigationIndex, ChatTurnProcessPresentation, LegacyConversationSlice, TurnNavigationItem,
} from '../contract/snapshot.ts'
import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
import { isAtOrOutside } from '../contract/group-tree.ts'
import { locationCoordinates } from './common.ts'
import { sessionRecallLabels, skillInvocationName } from './event-projection.ts'
import { GroupTreeSegments } from './group-tree-segments.ts'
@@ -457,7 +457,7 @@ function turnProcessPresentations(
if (location.kind !== 'turn' && location.kind !== 'step') continue
const current: TurnProcessPresentation = presentations.get(location.turn.turn) ?? {}
const controlAnchor = current.control?.data.controlAnchorSeq
if ((node.kind === 'user' || node.kind === 'turn-trigger' || node.kind === 'steering')
if (isAtOrOutside(node, 'input')
&& controlAnchor !== undefined
&& (controlAnchor === location.turn.start?.seq || node.anchorSeq < controlAnchor)) {
presentations.set(location.turn.turn, {
@@ -466,7 +466,7 @@ function turnProcessPresentations(
})
continue
}
if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) continue
if (isAtOrOutside(node, 'turn')) continue
presentations.set(location.turn.turn, {
...current,
earliestProcessAnchor: Math.min(current.earliestProcessAnchor ?? node.anchorSeq, node.anchorSeq),
@@ -497,7 +497,7 @@ function presentationPosition(
const openingInputAnchor = presentation.openingInputAnchor
if (openingInputAnchor !== undefined
&& node.anchorSeq < openingInputAnchor
&& !TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) {
&& !isAtOrOutside(node, 'turn')) {
return { anchor: openingInputAnchor, rank: 2, originalAnchor: node.anchorSeq }
}
if (presentation.control !== undefined && node.key === presentation.control.key) {
@@ -1148,6 +1148,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
|| previous.kind !== node.kind
|| previous.anchorSeq !== node.anchorSeq
|| previous.visibility !== node.visibility
|| previous.groupTree !== node.groupTree
|| locationIdentity(previous.location) !== locationIdentity(node.location)
structural ||= nodeStructural
if (!nodeStructural) contentOnly.push(node)
@@ -118,14 +118,14 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
// Human input claimed for Step 1 opens an exchange; Notice-woken Turns and
// later steering stay in the current exchange, so they never fold earlier Turns.
const location = contextLocation(context)
const opensExchange = (context.state.kind === 'user' || context.state.kind === 'steering')
&& location.kind === 'step' && location.step.step === 1
const human = context.state.kind === 'user' || context.state.kind === 'steering'
const opensExchange = human && location.kind === 'step' && location.step.step === 1
return chatNode(
context,
waking ? 'turn-trigger' : context.state.kind,
context.state.seq,
context.state,
{ location, groupTree: opensExchange ? 'exchange' : undefined },
{ location, groupTree: opensExchange ? 'exchange' : (human || waking) ? 'input' : undefined },
)
},
}
@@ -8,9 +8,9 @@ import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.
import type { ProcessActivitySummary, ProcessGroupData } from '../contract/process-groups.ts'
import { hasAssistantReplyContent } from '../contract/assistant-content.ts'
import { isVisibleChatNode } from '../contract/chat-visibility.ts'
import { isAtOrOutside } from '../contract/group-tree.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 {
@@ -44,6 +44,7 @@ function structureChanged(previous: ChatNode | undefined, current: ChatNode): bo
return previous === undefined || previous.kind !== current.kind
|| turnOf(previous) !== turnOf(current)
|| isVisibleChatNode(previous) !== isVisibleChatNode(current)
|| previous.groupTree !== current.groupTree
|| reasoning(previous) !== reasoning(current) || reply(previous) !== reply(current)
}
@@ -53,15 +54,6 @@ function readNode(input: ProcessInput, key: NodeKey): ChatNode {
return node
}
function questionReplyIds(input: ProcessInput, keys: readonly NodeKey[]): ReadonlySet<string> {
const ids = new Set<string>()
for (const key of keys) {
const node: ChatConversationViewNode = readNode(input, key)
if (node.kind === 'question-reply') ids.add(node.id)
}
return ids
}
/** One group's members and cached summary, refreshed together when its content changes. */
class ProcessGroup {
private nodes: readonly ChatNode[] = []
@@ -152,23 +144,17 @@ class TurnGroups {
}
let previous: NodeKey | undefined
let followed = false
const keys = input.readTurn(this.turn)
const replies = questionReplyIds(input, keys)
for (const key of keys) {
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)
// Both Definitions retain the same message id; only its question presentation renders.
if (node.kind === 'turn-trigger' && replies.has(node.id)) continue
if (INDEPENDENT.has(node.kind)) {
if (isAtOrOutside(node, 'process')) {
flush(true)
emit(key, { kind: 'node', key })
} else if (node.kind === 'turn-process') {
// A coalesced opening reply can sort before the control that folds its group.
emit(pending[0]?.key ?? key, { kind: 'node', key })
} else if (node.kind === 'assistant-step') {
// One Node, two rows: reasoning joins the step run, a reply stands outside it.
if (reasoning(node)) pending.push({ kind: 'node', key, groupPart: 'reasoning' })
if (reply(node)) {
flush(true)
@@ -291,14 +277,9 @@ export class ProcessState {
}
private rootEntries(input: ProcessInput): RenderEntry[] {
const unscoped = input.order.filter(key => readPosition(input, key).turn === undefined)
const replies = questionReplyIds(input, unscoped)
return input.order.flatMap<RenderEntry>((key) => {
const turn = readPosition(input, key).turn
if (turn === undefined) {
const node = readNode(input, key)
return node.kind === 'turn-trigger' && replies.has(node.id) ? [] : [{ kind: 'node', key }]
}
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)
@@ -84,7 +84,7 @@ export const retryDefinition: ConversationNodeDefinition<RetryState> = {
const current = attempts.at(-1)
if (current === undefined) return null
const data: RetryChatData = { attempts, current }
return chatNode(context, 'model-retry', attempts[0]?.seq ?? current.seq, data)
return chatNode(context, 'model-retry', attempts[0]?.seq ?? current.seq, data, { groupTree: 'process' })
},
}
@@ -91,7 +91,7 @@ export const turnErrorDefinition: ConversationNodeDefinition<TurnErrorState> = {
message: failure.message,
...failure.code === undefined ? {} : { code: failure.code },
}
return chatNode(context, 'turn-error', node.seq, node)
return chatNode(context, 'turn-error', node.seq, node, { groupTree: 'turn' })
},
}
@@ -69,7 +69,7 @@ export const turnMaxTokensDefinition: ConversationNodeDefinition<TurnMaxTokensSt
turn: state.turn,
step: lastStep(context),
}
return chatNode(context, 'turn-max-tokens', noticeAnchor(context, state.seq), node)
return chatNode(context, 'turn-max-tokens', noticeAnchor(context, state.seq), node, { groupTree: 'turn' })
},
}
@@ -3,7 +3,7 @@ import type { ChatNode } from '../contract/chat-nodes.ts'
import type {
ChatLocationNodeIndex, ChatNodeStore, ChatTurnProcessPresentation,
} from '../contract/snapshot.ts'
import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
import { isAtOrOutside } from '../contract/group-tree.ts'
function nodeTurn(node: ChatNode | undefined): number | undefined {
const location = node?.location
@@ -41,7 +41,7 @@ function derivePresentation(
let openingHumanAnchor: number | undefined
for (const key of keys) {
const node = nodes.get(key) as ChatNode | undefined
if ((node?.kind === 'user' || node?.kind === 'steering' || node?.kind === 'turn-trigger')
if (node !== undefined && isAtOrOutside(node, 'input')
&& (spec.controlAnchorSeq === location.turn.start?.seq || node.anchorSeq < spec.controlAnchorSeq)) {
openingHumanAnchor = Math.max(openingHumanAnchor ?? node.anchorSeq, node.anchorSeq)
}
@@ -53,12 +53,12 @@ function derivePresentation(
for (const key of keys) {
const node = nodes.get(key) as ChatNode | undefined
if (node === undefined || !isVisibleChatNode(node) || node.kind === 'turn-process') continue
if ((node.kind === 'user' || node.kind === 'steering' || node.kind === 'turn-trigger')
if (isAtOrOutside(node, 'input')
&& (openingHumanAnchor === undefined || node.anchorSeq > openingHumanAnchor)) {
hasInterleavedInput = true
if (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq) compactAnswer = false
}
if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)
if (isAtOrOutside(node, 'turn')
|| node.anchorSeq < spec.processStartSeq
|| (spec.answerAnchorSeq !== null && node.anchorSeq >= spec.answerAnchorSeq)) continue
if (node.kind !== 'assistant-step' || spec.answerStep === null || node.data.step !== spec.answerStep) {
@@ -296,6 +296,7 @@ export const turnProcessDefinition: ConversationNodeDefinition<TurnProcessState>
'turn-process',
data.controlAnchorSeq + CHAT_SYNTHETIC_SEQ_OFFSETS.processControl,
data,
{ groupTree: 'turn' },
)
},
}
@@ -135,7 +135,7 @@ export const turnTailDefinition: ConversationNodeDefinition<TurnTailState> = {
buildViewNode: (context) => {
const turn = turnLocation(context)
const data = turn?.data.get('turn-tail')
return data === undefined ? null : chatNode(context, 'turn-tail', data.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup, data)
return data === undefined ? null : chatNode(context, 'turn-tail', data.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup, data, { groupTree: 'turn' })
},
}
@@ -16,10 +16,10 @@ import { orderedVisibleChatNodes } from '../src/client/conversation-nodes/chat-s
import { ChatTurnProcessProjector } from '../src/client/conversation-nodes/turn-process-presentation.ts'
import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts'
import {
isSubagentDelegationTool, sameTurnProcessSpec, TURN_PROCESS_INDEPENDENT_KINDS,
isSubagentDelegationTool, sameTurnProcessSpec,
type TurnProcessSpec,
} from '../src/client/contract/turn-process.ts'
import { isAtOrOutside } from '../src/client/contract/group-tree.ts'
import { isAtOrOutside, type ChatGroupTree } from '../src/client/contract/group-tree.ts'
const EMPTY: readonly never[] = []
@@ -245,6 +245,19 @@ class FixtureTurnDataStore implements ConversationLocationDataStore<Conversation
}
}
/** Positions the production Definitions declare for the legacy kinds this fixture projects. */
function fixtureGroupTree(kind: string, inTurn: boolean): ChatGroupTree | undefined {
switch (kind) {
// A fixture user message inside a Turn stands for its opening human input.
case 'user': return inTurn ? 'exchange' : 'input'
case 'steering': return 'input'
case 'turn-error':
case 'turn-max-tokens': return 'turn'
case 'model-retry': return 'process'
default: return undefined
}
}
function assistantData(node: AssistantMessageNode): FinalAssistantChatData {
return {
status: node.interrupted === true ? 'interrupted' as const : 'settled' as const,
@@ -272,8 +285,7 @@ function settledNode(
? { kind: 'session' as const }
: { kind: 'turn' as const, turn },
visibility: 'visible' as const,
// A fixture user message inside a Turn stands for its opening human input.
groupTree: node.kind === 'user' && turn !== undefined ? 'exchange' as const : undefined,
groupTree: fixtureGroupTree(node.kind, turn !== undefined),
}
switch (node.kind) {
case 'assistant':
@@ -426,7 +438,7 @@ export function chatSnapshotFixture(input: {
const turn = turns.get(turnNumber)
const controlAnchorSeq = controlAnchor?.anchorSeq ?? turn?.start?.seq
if (controlAnchorSeq === undefined) continue
const processStart = inTurn.find(candidate => !TURN_PROCESS_INDEPENDENT_KINDS.has(candidate.kind))
const processStart = inTurn.find(candidate => !isAtOrOutside(candidate, 'turn'))
?? controlAnchor
const inlineReasoning = answer?.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '') === true
const candidate: TurnProcessSpec = {
@@ -463,6 +475,7 @@ export function chatSnapshotFixture(input: {
anchorSeq: spec.controlAnchorSeq - 0.1,
location: { kind: 'turn', turn },
visibility: 'visible',
groupTree: 'turn',
data: spec,
})
}
@@ -503,6 +516,7 @@ export function chatSnapshotFixture(input: {
anchorSeq: endSeq,
location: { kind: 'turn', turn },
visibility: 'visible',
groupTree: 'turn',
data: tailData,
})
}
@@ -2005,6 +2005,23 @@ describe('ChatView', () => {
expect(renderedFlowKinds(view.container)).toEqual(['user', 'turn-process'])
})
it('matches echoes past an input Node whose payload carries no message source', () => {
const base = chatSnapshotFixture({ nodes: [user(1, 'question')] })
const snapshot = new ChatSnapshotBuilder().replace({
nodes: [...base.nodes.values(), {
key: 'plugin-input', id: 'plugin-input', kind: 'plugin-input', target: 'chat', anchorSeq: 2,
location: { kind: 'session' }, visibility: 'visible', groupTree: 'input', data: null,
}],
timeline: base.timeline,
})
const h = makeHarness({}, { pendingSubmissions: [{
requestId: 'pending' as never, placement: 'transcript', time: 1_000, text: 'pending text', attachments: [],
}] }, snapshot)
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText('pending text')).toBeTruthy()
expect(view.container.querySelector('[data-submission-echo]')).not.toBeNull()
})
it.each(['ABC', 'ACB', 'BAC', 'BCA', 'CAB', 'CBA'])(
'uses the first local opening echo until Host admission identifies the input (%s)', (hostOrder) => {
const pending: SessionSnapshot['pendingSubmissions'] = ['A', 'B', 'C'].map(text => ({
@@ -659,7 +659,7 @@ describe('built-in conversation node Definitions', () => {
}
})
it('opens exchanges only for human input claimed for Step 1', () => {
it('opens exchanges only for human input claimed for Step 1; every other input stays an input', () => {
const human = textMessage('block-human', 'first task')
const steer = textMessage('block-steer', 'adjust course')
const notice = { ...textMessage('block-notice', 'background work finished'), source: { kind: 'schedule' } }
@@ -693,7 +693,7 @@ describe('built-in conversation node Definitions', () => {
at(15, 'turn/start', { turn: 2 }),
at(16, 'agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }),
])
expect(inputs(snapshot(value))).toEqual([['user', 'exchange'], ['steering', null]])
expect(inputs(snapshot(value))).toEqual([['user', 'exchange'], ['steering', 'input']])
expect(bottomTurns(snapshot(value))).toEqual([1, 2])
value.append(at(17, 'step/start', { turn: 2, step: 1 }))
@@ -702,7 +702,7 @@ describe('built-in conversation node Definitions', () => {
value.append(at(20, 'step/end', { turn: 2, step: 1 }))
value.append(at(21, 'turn/end', { turn: 2, reason: { kind: 'completed' } }))
value.flush()
expect(inputs(snapshot(value))).toEqual([['user', 'exchange'], ['steering', null], ['turn-trigger', null]])
expect(inputs(snapshot(value))).toEqual([['user', 'exchange'], ['steering', 'input'], ['turn-trigger', 'input']])
expect(bottomTurns(snapshot(value))).toEqual([1, 2])
value.append(at(22, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [idle] }))
@@ -25,12 +25,13 @@ function members(segments: GroupTreeSegments, turns: readonly number[]): number[
describe('Chat group tree', () => {
it('orders positions from the outermost exchange inward', () => {
const at = (groupTree?: ChatGroupTree) => (['exchange', 'turn', 'process'] as const)
const at = (groupTree?: ChatGroupTree) => (['exchange', 'input', 'turn', 'process'] as const)
.map(position => isAtOrOutside({ groupTree }, position))
expect(at('exchange')).toEqual([true, true, true])
expect(at('turn')).toEqual([false, true, true])
expect(at('process')).toEqual([false, false, true])
expect(at(undefined)).toEqual([false, false, false])
expect(at('exchange')).toEqual([true, true, true, true])
expect(at('input')).toEqual([false, true, true, true])
expect(at('turn')).toEqual([false, false, true, true])
expect(at('process')).toEqual([false, false, false, true])
expect(at(undefined)).toEqual([false, false, false, false])
})
it('starts the latest segment at the last opening Turn and reports only the Turns that moved', () => {
@@ -45,23 +45,13 @@ function separator(seq: number, inTurn: boolean): ChatNode<'user'> | ChatNode<'s
const user: ChatNode<'user'> = {
key: `user:${seq}`, id: `user:${seq}`, kind: 'user', target: 'chat', anchorSeq: seq,
location: inTurn ? { kind: 'turn', turn } : { kind: 'session' }, visibility: 'visible',
// Neither location is the Step 1 that opens an exchange.
groupTree: 'input',
data: { kind: 'user', seq, time: seq, content: [{ type: 'text', text: 'continue' }], source: null },
}
return inTurn ? { ...user, kind: 'steering', data: { ...user.data, kind: 'steering', messageId: 'steer' as MessageId } } : user
}
function replyNodes(inTurn: boolean): readonly [ChatConversationViewNode, ChatConversationViewNode] {
const trigger: ChatConversationViewNode = {
key: 'generic-reply', id: 'reply-message', kind: 'turn-trigger', target: 'chat', anchorSeq: 6,
location: inTurn ? { kind: 'turn', turn } : { kind: 'session' }, visibility: 'visible',
data: {
kind: 'context', seq: 6, time: 6, content: [{ type: 'text', text: 'answer' }],
source: { kind: 'user-question-reply' }, producer: { role: 'inject', label: null }, form: null,
},
}
return [trigger, { ...trigger, key: 'question-reply', kind: 'question-reply', data: {} }]
}
function harness(nodes: readonly ChatConversationViewNode[], currentTimeline = timeline) {
const builder = new ChatSnapshotBuilder()
const state = new ProcessState()
@@ -243,69 +233,51 @@ describe('Definition-owned Chat process groups', () => {
expect(source.getSnapshot()?.data.summary).toEqual({ counts: [{ kind, count: 1 }], running: undefined, runningDetail: '' })
})
it.each(['a-trigger', 'z-trigger'])('renders one question reply without splitting its group for duplicate %s', (key) => {
const [generic, reply] = replyNodes(true)
const trigger = { ...generic, key }
const h = harness([tool('before', 4), trigger, reply, tool('after', 8)])
expect(h.store.entries.map(entry => entry.kind)).toEqual(['group'])
const group = h.store.entries[0]!
if (group.kind !== 'group') throw new Error('expected group')
expect(h.store.groupSource(group.key).getSnapshot()?.members.map(member => member.key))
.toEqual(['before', reply.key, 'after'])
expect(h.snapshot.nodes.get(trigger.key)).toBeDefined()
expect(h.snapshot.nodes.get(reply.key)).toBeDefined()
it.each(['process', 'turn', 'input'] as const)('ends the group at a Node declared at %s, whatever its kind', (position) => {
const h = harness([tool('before', 4), { ...tool('standalone', 5), groupTree: position }, tool('after', 8)])
expect(h.store.entries.map(entry => entry.kind === 'group' ? 'group' : entry.key)).toEqual(['group', 'standalone', 'group'])
})
it.each([0.9, 5])('keeps the Turn control before its coalesced opening reply at seq %s', (anchorSeq) => {
it('regroups when a Node changes its position without changing its kind', () => {
const middle = tool('middle', 5)
const h = harness([tool('before', 4), middle, tool('after', 8)])
expect(h.store.entries.map(entry => entry.kind)).toEqual(['group'])
h.builder.apply({ upserts: [{ ...middle, groupTree: 'process' }], timeline })
h.commit()
expect(h.store.entries.map(entry => entry.kind === 'group' ? 'group' : entry.key)).toEqual(['group', 'middle', 'group'])
})
it('keeps the Turn control as an independent root before the process it folds', () => {
const control: ChatNode<'turn-process'> = {
key: 'control', id: '1', kind: 'turn-process', target: 'chat', anchorSeq,
location: { kind: 'turn', turn }, visibility: 'visible',
key: 'control', id: '1', kind: 'turn-process', target: 'chat', anchorSeq: 0.9,
location: { kind: 'turn', turn }, visibility: 'visible', groupTree: 'turn',
data: {
turn: 1, controlAnchorSeq: 1, processStartSeq: 1, answerAnchorSeq: null,
answerStep: null, inlineReasoning: false, messageCount: 0, toolCallCount: 1, subagentCount: 0,
},
}
const h = harness([control, ...replyNodes(true), tool('after', 8)])
const h = harness([control, tool('first', 6), tool('after', 8)])
expect(h.store.entries.map(entry => entry.kind)).toEqual(['node', 'group'])
expect(h.store.entries[0]).toEqual({ kind: 'node', key: control.key })
})
it('aggregates a newly projected reply with its existing trigger', () => {
const [trigger, reply] = replyNodes(true)
const h = harness([tool('before', 4), trigger, tool('after', 8)])
h.builder.apply({ upserts: [reply], timeline })
h.commit()
expect(h.store.entries.map(entry => entry.kind)).toEqual(['group'])
const group = h.store.entries[0]!
it('sorts a later-anchored Turn control before the process of a Turn without opening input', () => {
// A Turn opened by a reply that declares no position: the control's own anchor follows the reply.
const control: ChatNode<'turn-process'> = {
key: 'control', id: '1', kind: 'turn-process', target: 'chat', anchorSeq: 6.9,
location: { kind: 'turn', turn }, visibility: 'visible', groupTree: 'turn',
data: {
turn: 1, controlAnchorSeq: 6, processStartSeq: 1, answerAnchorSeq: null,
answerStep: null, inlineReasoning: false, messageCount: 0, toolCallCount: 1, subagentCount: 0,
},
}
const h = harness([tool('reply', 6), control, tool('after', 8)])
expect(h.snapshot.order).toEqual(['control', 'reply', 'after'])
expect(h.store.entries.map(entry => entry.kind)).toEqual(['node', 'group'])
expect(h.store.entries[0]).toEqual({ kind: 'node', key: control.key })
const group = h.store.entries[1]!
if (group.kind !== 'group') throw new Error('expected group')
expect(h.store.groupSource(group.key).getSnapshot()?.members.map(member => member.key))
.toEqual(['before', reply.key, 'after'])
h.builder.apply({ upserts: [{ ...reply, visibility: 'hidden' }], timeline })
h.commit()
expect(h.store.entries.map(entry => entry.kind)).toEqual(['group', 'node', 'group'])
expect(h.store.entries[1]).toEqual({ kind: 'node', key: trigger.key })
})
it('aggregates reply projections before their Turn has been loaded', () => {
const [trigger, reply] = replyNodes(false)
const h = harness([trigger, reply])
expect(h.store.entries).toEqual([{ kind: 'node', key: reply.key }])
expect(h.snapshot.nodes.get(trigger.key)).toBeDefined()
})
it('keeps unscoped aggregation current when a reply arrives', () => {
const [trigger, reply] = replyNodes(false)
const h = harness([trigger])
h.builder.apply({ upserts: [reply], timeline })
h.commit()
expect(h.store.entries).toEqual([{ kind: 'node', key: reply.key }])
})
it.each([true, false])('does not merge identical text from different messages, scoped: %s', (inTurn) => {
const [trigger, reply] = replyNodes(inTurn)
const h = harness([trigger, { ...reply, id: 'different-message', data: trigger.data }])
expect(h.store.entries).toContainEqual({ kind: 'node', key: trigger.key })
expect(h.store.groupSource(group.key).getSnapshot()?.members.map(member => member.key)).toEqual(['reply', 'after'])
})
it('retains a group through repeated prepends and a later append', () => {
@@ -15,8 +15,8 @@
en: eef4f18c3972ccc4
zh: 6be65c6ac84ce497
/deepseek-ai-dsh-client-ui-conversation/conversation-assembly:
en: 6aefa09bb22fdf81
zh: 0641c4196437cf53
en: ef43c4f149195939
zh: 49fc6c83fd3a05ce
/deepseek-ai-dsh-client-ui-conversation/sent-user-text:
en: 9912b286384c41d7
zh: 3e994887c1948677
+1 -1
View File
@@ -38,7 +38,7 @@ Definitions may share the same read-only Match and Location for one input and li
Registration accepts the existing callable `match` or a read-only event-type-to-handler table. The Registry copies table entries and precomputes ordered candidate Sets on registration changes. Function-form Definitions keep their callable API and receive every event; table handlers receive only their declared types. Changing a table requires unregistering and registering again. Legacy assembler adapters with only `entries()` and `fallbackEntry()` retain their original dispatch path.
All accepted Matches update State in order. Repeated updates to one Context are coalesced between flushes. A Context retains its key while kind and id match; Definition-facing Context snapshots remain distinct objects. The fallback handles only inputs not claimed by an ordinary Definition for its target.
All accepted Matches update State in order. Repeated updates to one Context are coalesced between flushes. A Context retains its key while kind and id match; Definition-facing Context snapshots remain distinct objects. The fallback handles only inputs that no ordinary Definition of its target matched. A Definition can return `exclusive: true` from `match()` to claim an event on its target: other Definitions of that target do not receive the event and the target's fallback does not run ([semantics](../../../docs/subsystems/conversation.md#definition-and-typed-chat-payload)).
Location lookup indexes Steps by Turn and Step number, so a non-boundary append does not scan the Turn's Step array. Each input's numeric coordinates and resolved Location share one private index record; published Location values remain immutable. Boundary updates, replacement, and prepend keep the indexes aligned with the current timeline.
+1 -1
View File
@@ -38,7 +38,7 @@ kind: "package-reference"
注册入口接受原有可调用的 `match`,或只读的事件类型到处理函数表。Registry 在注册关系变化时复制表条目并预计算有序候选 Set。函数形式的 Definition 保持原有可调用 API,接收全部事件;表中的处理函数只接收已声明类型。修改表需要注销后重新注册。只有 `entries()` 与 `fallbackEntry()` 的旧 assembler 适配器保留原有分发路径。
所有接受的 Match 按顺序更新 State。两次 flush 之间,同一 Context 的重复更新合批发布。kind 和 id 相同时,Context 保持原有 key;传给 Definition 的 Context 快照仍为独立对象。fallback 只处理未被其目标的普通 Definition 认领的输入。
所有接受的 Match 按顺序更新 State。两次 flush 之间,同一 Context 的重复更新合批发布。kind 和 id 相同时,Context 保持原有 key;传给 Definition 的 Context 快照仍为独立对象。fallback 只处理其目标上没有普通 Definition 匹配的输入。Definition 可以从 `match()` 返回 `exclusive: true`,在其目标上认领一个事件:该目标的其他 Definition 收不到这个事件,该目标的 fallback 也不运行([语义](../../../docs/subsystems/conversation.zh.md#definition-and-typed-chat-payload))。
Location 查询按 Turn 和 Step 编号索引 Step,因此非边界 append 不扫描 Turn 的 Step 数组。每个输入的数值坐标与解析出的 Location 共用一条私有索引记录;已发布的 Location 值保持不可变。边界更新、完整替换和向前补页使这些索引与当前时间线保持一致。
@@ -8,6 +8,16 @@ import type {
export interface ConversationMatchResult {
readonly id: string
readonly role: 'start' | 'update'
/**
* Claim this event on the Definition's target: no other Definition of the
* same target receives it, and that target's unmatched-event fallback does
* not run. Definitions of other targets and Definitions without a target
* still receive it. Two claims on one target, or a claim from a Definition
* without a target, throw during dispatch. A claim returned by the fallback
* itself has no effect, because the fallback runs only when no Definition of
* its target matched.
*/
readonly exclusive?: true
}
/** Merge-extensible business values published against one Turn. */
@@ -3,7 +3,7 @@ import type {
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand'
import type {
ConversationContextReader, ConversationLocationData, ConversationMatch,
ConversationContextReader, ConversationLocationData, ConversationMatch, ConversationMatchResult,
ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext,
ConversationLocationDataScope, ConversationPublication, ConversationViewBuilder,
ConversationStartMatch,
@@ -186,6 +186,9 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
private readonly groups = new Map<string, GroupState>()
private readonly pendingGroupStores = new Set<ConversationGroupStore<unknown>>()
private readonly activeTargets = new Set<string>()
/** Matches of the event in dispatch, kept by index; dispatch is not reentrant and reuses both arrays. */
private readonly matchedDefinitions: ConversationNodeDefinition[] = []
private readonly matchedResults: ConversationMatchResult[] = []
private hasMore = false
private replacePending = true
private timelineDirty = true
@@ -493,37 +496,58 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
? startMatch ??= conversationMatch(input, role, location)
: updateMatch ??= conversationMatch(input, role, location)
}
let firstTarget: string | undefined
let secondTarget: string | undefined
let otherTargets: Set<string> | undefined
let publication: ConversationPublication = 'none'
const definitions = this.matchedDefinitions
const results = this.matchedResults
definitions.length = 0
results.length = 0
const routes = this.eventDefinitions.forEvent?.(event.type)
if (routes === undefined) {
for (const definition of this.eventDefinitions.entries()) {
const result = definition.match(event)
if (result === null) continue
// Inline target tracking avoids an additional closure allocation for each event.
/* jscpd:ignore-start */
if (definition.target !== undefined && definition.target !== firstTarget && definition.target !== secondTarget) {
if (firstTarget === undefined) firstTarget = definition.target
else if (secondTarget === undefined) secondTarget = definition.target
else (otherTargets ??= new Set()).add(definition.target)
}
publication = maximumPublication(publication, accept.call(this, definition, result.id, matchFor(result.role)))
/* jscpd:ignore-end */
definitions.push(definition)
results.push(result)
}
} else {
for (const { definition, match } of routes) {
const result = match(event)
if (result === null) continue
if (definition.target !== undefined && definition.target !== firstTarget && definition.target !== secondTarget) {
if (firstTarget === undefined) firstTarget = definition.target
else if (secondTarget === undefined) secondTarget = definition.target
else (otherTargets ??= new Set()).add(definition.target)
}
publication = maximumPublication(publication, accept.call(this, definition, result.id, matchFor(result.role)))
definitions.push(definition)
results.push(result)
}
}
// Claims are resolved before any acceptance, so the outcome does not depend on registration order.
let claims: Map<string, string> | undefined
for (let index = 0; index < results.length; index++) {
if ((results[index] as ConversationMatchResult).exclusive !== true) continue
const definition = definitions[index] as ConversationNodeDefinition
const target = definition.target
if (target === undefined) {
throw new Error(`conversation Definition "${definition.kind}" claims ${event.type} at seq ${String(event.seq)} without a target`)
}
const claimant = claims?.get(target)
if (claimant !== undefined) {
throw new Error(`conversation Definitions "${claimant}" and "${definition.kind}" both claim ${event.type} at seq ${String(event.seq)} on target "${target}"`)
}
(claims ??= new Map()).set(target, definition.kind)
}
let firstTarget: string | undefined
let secondTarget: string | undefined
let otherTargets: Set<string> | undefined
let publication: ConversationPublication = 'none'
for (let index = 0; index < results.length; index++) {
const definition = definitions[index] as ConversationNodeDefinition
const result = results[index] as ConversationMatchResult
const target = definition.target
if (target !== undefined && result.exclusive !== true && claims?.has(target) === true) continue
// Inline target tracking avoids an additional closure allocation for each event.
if (target !== undefined && target !== firstTarget && target !== secondTarget) {
if (firstTarget === undefined) firstTarget = target
else if (secondTarget === undefined) secondTarget = target
else (otherTargets ??= new Set()).add(target)
}
publication = maximumPublication(publication, accept.call(this, definition, result.id, matchFor(result.role)))
}
const fallback = this.eventDefinitions.fallbackEntry()
const target = fallback?.target
if (fallback !== undefined && target !== undefined
@@ -1809,6 +1809,112 @@ describe('ConversationNodeAssembler', () => {
expect(testSnapshot(assembler)?.order).toEqual([])
})
describe('exclusive matches', () => {
const run = (seq: number, name: string) =>
input(at(SessionSeq(seq), 'command/run', { commandId: `c${String(seq)}`, name }))
const commandName = (event: SessionEventLike): string | undefined => (event.type as string) === 'command/run'
? (event.data as { readonly name: string }).name
: undefined
function genericDefinition(start = vi.fn(() => 'generic'), target = 'test'): ConversationNodeDefinition<string> {
return {
kind: `generic-${target}`,
target,
match: event => commandName(event) === undefined ? null : { id: String(event.seq), role: 'start' },
start,
update: context => context.state,
buildViewNode: context => target === 'test' ? node(context, context.state) : null,
}
}
// `null` builds a Definition without a target; an omitted argument keeps the test target.
function claimingDefinition(kind = 'specific', target: string | null = 'test'): ConversationNodeDefinition<string> {
return {
kind,
...target === null ? {} : { target },
match: event => commandName(event) === 'claimed'
? { id: String(event.seq), role: 'start', exclusive: true }
: null,
start: () => kind,
update: context => context.state,
...target === null ? {} : { buildViewNode: (context: ConversationNodeContext<string>) => node(context, context.state) },
}
}
function nodeData(assembler: ConversationNodeAssembler): unknown[] {
return [...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)
}
it.each(['claim first', 'claim last'] as const)(
'withholds the claimed event from other Definitions of the target when registered %s',
(order) => {
const genericStart = vi.fn(() => 'generic')
const definitions = [claimingDefinition(), genericDefinition(genericStart)]
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions(order === 'claim first' ? definitions : [...definitions].reverse()),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([run(1, 'claimed'), run(2, 'other')], false)
assembler.flush()
assembler.append(run(3, 'claimed'))
assembler.flush()
expect(genericStart).toHaveBeenCalledOnce()
expect(nodeData(assembler)).toEqual(['specific', 'generic', 'specific'])
},
)
it('keeps delivering the claimed event to other targets, target-less Definitions, and no fallback', () => {
const otherTargetStart = vi.fn(() => 'generic')
const stateOnlyStart = vi.fn(() => null)
const stateOnly: ConversationNodeDefinition<null> = {
kind: 'state-only',
match: event => commandName(event) === undefined ? null : { id: String(event.seq), role: 'start' },
start: stateOnlyStart,
update: context => context.state,
}
const fallbackStart = vi.fn(() => 'fallback')
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions(
[claimingDefinition(), genericDefinition(otherTargetStart, 'trajectory'), stateOnly],
fallbackDefinition(fallbackStart),
),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([run(1, 'claimed')], false)
assembler.flush()
expect(otherTargetStart).toHaveBeenCalledOnce()
expect(stateOnlyStart).toHaveBeenCalledOnce()
expect(fallbackStart).not.toHaveBeenCalled()
expect(nodeData(assembler)).toEqual(['specific'])
})
it('rejects two claims on one target', () => {
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([claimingDefinition('first'), claimingDefinition('second')]),
new TestViewDefinitions([testView()]),
)
expect(() => assembler.replaceWindow([run(1, 'claimed')], false)).toThrow(
'conversation Definitions "first" and "second" both claim command/run at seq 1 on target "test"',
)
})
it('rejects a claim from a Definition without a target', () => {
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([claimingDefinition('untargeted', null)]),
new TestViewDefinitions([testView()]),
)
expect(() => assembler.replaceWindow([run(1, 'claimed')], false)).toThrow(
'conversation Definition "untargeted" claims command/run at seq 1 without a target',
)
})
})
it('rejects withdrawing a previously materialized Node during an incremental update', () => {
const definition: ConversationNodeDefinition<boolean> = {
kind: 'toggle',
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package:
en: 87e0c952a5bfcc41
zh: 8824e1b588fae213
en: 005ae2cf873c0a2e
zh: bc7ca9472105a6a0
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle:
en: fcb2e69c8b028c0a
zh: d505a075679974eb
@@ -29,6 +29,8 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b
Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists all offered official bundles, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching a bundle on or off does not change its group or position. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section.
The heading moves its complete action group to another line when space is insufficient. Action labels stay on one line, and the information button follows the subtitle text.
Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` of a package-root plugin; subpath plugins never read one; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the package root's manifest `icon` or exported `./icon`, or a subpath plugin's exported `<subpath>/icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information.
On-demand Official cards are visible offline before installation. Turning one On opens the existing install progress dialog, installs the [target offered by the running Host](../../boot/plugin-manager/README.md#use-this-package), and selects its layer. The same registry fallback, cancellation, and script-approval controls apply. Turning Off keeps the dependency installed; Remove is separate. A different version or development link source offers **Update**; details show current and offered sources and versions. Updates preserve selection and report whether a restart is required. Missing or unreadable packages keep their recorded dependency source alongside the diagnostic. An enabled bundle can still be switched Off when its checkout target is unavailable.
@@ -29,6 +29,8 @@ kind: "package-reference"
在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出全部官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。启停组合包不会改变它的分组或位置。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。
空间不足时,页头将整个操作组移到下一行。操作文案保持单行,信息按钮紧随副标题文字。
已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到包根插件可访问的 `package.json`,子路径插件从不读取;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示包根的清单 `icon` 或导出的 `./icon`,或子路径插件导出的 `<子路径>/icon`;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。
官方按需卡片在安装前即可离线显示。开启会打开已有的安装进度对话框,安装[当前 Host 提供的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择配置层。安装源回退、取消和脚本审批继续使用同一组控件。关闭保留依赖;移除是独立操作。版本或开发链接来源不同会提供**更新**;详情显示当前及所提供的来源和版本。更新保留选择状态,并报告是否需要重启。文件缺失或不可读的包在诊断旁保留记录的依赖来源。已启用的 bundle 在 checkout 目标不可用时仍可关闭。
@@ -22,6 +22,7 @@
.pageHead {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
@@ -39,6 +40,11 @@
padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px));
}
.pageHeading {
min-width: 0;
max-width: 100%;
}
.pageTitle {
margin: 0;
font-size: 20px;
@@ -66,10 +72,11 @@
.toolbar {
display: flex;
flex-wrap: wrap;
flex: none;
align-items: center;
justify-content: flex-end;
gap: 16px;
white-space: nowrap;
}
.addMenu {
@@ -1484,7 +1484,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
{showsMainList
? (
<header className={css.pageHead} data-window-drag>
<div>
<div className={css.pageHeading}>
<h1 className={css.pageTitle}>{t('title')}</h1>
<div className={css.pageIntro}>
<span>{t('intro')}</span>
+2 -2
View File
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 405b30feaf48c087
/deepseek-ai-dsh-client-ui-schedule/use-this-package:
en: c668e3d223102e47
zh: efedf3996679fb17
en: edba0e1573f3ce42
zh: 77875b469d94cc62
/deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source:
en: 6bd6882dbbbefcb0
zh: 03aa15f1a65d13dc
+2
View File
@@ -27,6 +27,8 @@ Manage active and inactive tasks from the Automation tasks page and the right Si
The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation.
The page header keeps the title at its natural width and moves New to another line when the two no longer fit together. The action label stays on one line.
Selecting a task from the list opens Rules; refreshing that task preserves the selected tab. Rules edits an active task's name, instruction, and run time; the edits stay in a local draft until saved, and inactive tasks remain read-only. Unmodified Left/Right arrows cycle the tabs, Home/End select the first/last tab, and keyboard focus follows the selection. The tabs do not consume these keys with Alt, Ctrl, Meta, or Shift. The linked-Session control in the detail tab strip stays available on the Rules tab, and confirmed deletion is available from either view.
Rules shows the rule in a Run time card whose rows edit a local draft: Repeat is a menu of Weekly, Monday to Friday, Every day, Every N hours, Every N minutes, Every N seconds, Once, and Custom (cron), a weekly rule adds a Weekday row, a fixed-rate choice edits only the interval quantity in the unit it names, a one-shot rule edits separate Date and Time rows, and Custom (cron) edits one Cron expression row holding a five-field expression, interpreted in the selected time zone. The card parses that expression in the browser and, under the row, shows a localized sentence describing it; an expression the Host would reject shows the localized validation message in place of that sentence and blocks Save. A stored weekly rule whose weekday set is exactly Monday through Friday selects the Monday to Friday choice. The Date row is a read-only trigger showing the staged date as `YYYY/MM/DD` and opening a month calendar with localized weekday headings, month navigation, and a marked today; the draft and the submitted text keep the ISO form; the Time row is a read-only trigger showing one 24-hour clock and opening three scrolling columns of hours, minutes, and seconds with the staged value marked and scrolled into view. A calendar or clock pick stages the draft under the same staged save as every other row, arrow keys walk the open column or grid, Enter stages the marked value, Escape closes the panel and returns focus to its row, and a disabled row cannot open one. Clock rows show whole seconds, an untouched row keeps a stored millisecond value for its submit, and editing a time submits whole-second precision. Time zone is a searchable, height-bounded menu. An unlabeled top block keeps the five most recently selected exact zones in browser-local storage; first use seeds the actual system zone and UTC rather than inferring a location from UI language. The system zone is marked, and the remaining inventory is ordered by current UTC offset and canonical IANA id. Each visible label puts `UTC±HH:MM` before the localized zone name; canonical ids stay internal but remain searchable. Zones that ICU renders with the same offset and localized name share one visible row; every grouped id remains searchable, and an already stored id remains selected instead of being rewritten. The inventory comes from `Intl.supportedValuesOf('timeZone')`, and names come from `Intl.DateTimeFormat(..., { timeZoneName: 'longGeneric' })`: both are browser ICU/CLDR data, not a scraped or product-maintained translation table. There is therefore no crawler or generated zone-name resource to refresh. A third-party locale supplies its ordinary `time.locale` value and automatically receives the corresponding ICU names; only the selector's surrounding UI copy needs translation. When the runtime cannot enumerate zones, a short identifier fallback is offered, and a stored zone outside the offered set is kept. Daily, Weekly, and Cron keep their stored rule and IANA zone, and each frequency line names the zone only when it differs from the host's system zone. A one-shot After/At seeds the date and time from the record's stored zone, or this device's zone when the record stores none, so the seeded clock names the stored instant; changing that zone keeps the entered clock values and changes the instant. A kind the card seeds on selection uses the committed occurrence's clock and the choice's zone, which is the record's stored zone or this device's zone, a seeded weekly rule starts from that occurrence's weekday in that zone, and a seeded cron rule states that occurrence's clock as a daily expression. Every accepts at least 60 whole seconds, independent of time zones. One Save submits the complete expected record with the staged change and preserves the id, original Session, latest receipt, and saved history; [Schedule](../../schedule/schedule/README.md#use-this-package) defines future-target selection, reanchoring, and unchanged-rule no-ops.
+2
View File
@@ -27,6 +27,8 @@ kind: "package-reference"
随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。
页头按标题的自然宽度排布;标题和“新建”无法并排容纳时,将按钮移到下一行。操作文案保持单行。
从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。
“规则”在“运行时间”卡片中以行编辑本地草稿:“重复”是含每周、周一至周五、每天、每 N 小时、每 N 分钟、每 N 秒、仅一次和自定义(cron)的菜单,每周规则增加“星期”行,固定速率选项只修改它所属单位的间隔数量,一次性规则用独立的“日期”和“时间”两行编辑目标,自定义(cron)用一行“Cron 表达式”编辑按所选时区解释的 5 字段表达式。卡片在浏览器中解析该表达式,并在该行下方显示描述它的本地化语句;Host 会拒绝的表达式改为显示本地化校验信息,并在解析通过前阻止保存。已存储的每周规则若星期集合恰好为周一至周五,则选中“周一至周五”选项。“日期”行是只读触发器,以 `YYYY/MM/DD` 显示暂存的日期并展开带本地化星期表头、月份切换和今天标记的月历;草稿与提交文本保持 ISO 形态;“时间”行是只读触发器,显示统一的 24 小时时钟,并展开时、分、秒三列可滚动列表,当前值高亮并在打开时滚动到可见。在月历或时钟上点选即按与其他行相同的分阶段保存写入草稿;方向键在打开的列或网格内移动,Enter 选定当前值,Escape 关闭面板并把焦点还给所在行,被禁用的行无法打开面板。时钟行显示到秒,未改动的行在提交时保留已存储的毫秒值,编辑时间时按整秒精度提交。“时区”使用高度受限的可搜索菜单。顶部无标题区域保留浏览器本地最近选过的 5 个准确时区;首次使用按真实系统时区和 UTC 初始化,而不根据界面语言猜测所在地。系统时区带有标记,其余清单按当前 UTC 偏移、再按规范 IANA 标识排序。可见文案把 `UTC±HH:MM` 放在本地化时区名称之前;IANA 标识仅在内部使用,但可用于搜索。ICU 显示成相同偏移和本地化名称的多个时区只占一行;所有被合并的标识仍可用于搜索,任务已保存的标识也会继续作为选中值,不会被改写。时区清单来自 `Intl.supportedValuesOf('timeZone')`,名称来自 `Intl.DateTimeFormat(..., { timeZoneName: 'longGeneric' })`:两者都是浏览器内置的 ICU/CLDR 数据,不是抓取或产品自维护的翻译表。因此没有需要刷新的爬虫或生成时区资源;第三方语言包只需提供常规的 `time.locale` 值,即可自动获得对应的 ICU 名称,仅需翻译选择器周边的 UI 文案。运行时无法枚举时改用简短的标识候选列表,已存储但不在其中的时区会保留。每天、每周和 cron 保留各自已存储的规则与时区,其频率行仅在时区与宿主系统时区不同时标出时区。一次性 After/At 以记录已存储的时区初始化日期和时间;记录未保存时区时取本设备时区,初始钟表值在该时区下仍表示同一瞬时;更改时区会保留输入的钟表值并改变时点。因选择种类而重新初始化的种类使用已提交发生时点的钟表值和该选项的时区,该时区取记录已存储的时区或本设备时区:每周的星期取自该发生时点在该时区下的星期,cron 则把该发生时点的钟表值写成每天执行的表达式。固定间隔至少为 60 整秒,不受时区影响。一次保存以分阶段的变更提交完整预期记录,并保留 id、原会话、最近一次回执及已保存历史;未来目标选择、重新确定起算时间和未变更规则的无写入语义见 [Schedule](../../schedule/schedule/README.zh.md#use-this-package)。
@@ -45,9 +45,10 @@
padding: 0 clamp(24px, 4vw, 48px) 48px;
}
/* The page name and the creation actions on one line. */
/* Page title and creation actions. */
.pageHeading {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
@@ -68,8 +69,9 @@
their titles share one size. The theme carries no size token for this step,
which is why every first-level page states the same literal. */
.pageHeading h1 {
flex: 1;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
margin: 0;
font-size: 20px;
line-height: 28px;
@@ -82,6 +84,7 @@
flex: none;
align-items: center;
gap: 16px;
white-space: nowrap;
}
/* The Plugins toolbar's Add-plugin step: a 32px capsule with the 13px label. */
@@ -142,13 +142,16 @@ function replySource(
: null
}
/** Late-reply projection owned by the questions UI. */
/**
* Late-reply projection owned by the questions UI. It claims the reply message
* on the Chat target, so the generic message Definition never presents it.
*/
export const questionReplyDefinition: ConversationNodeDefinition<QuestionReplyState> = {
kind: 'user-question-reply',
target: 'chat',
match: (event) => {
if (event.type !== 'user/message' || !isAppendSurfaceEvent(event)) return null
return replySource(event) === null ? null : { id: String(event.data.id), role: 'start' }
return replySource(event) === null ? null : { id: String(event.data.id), role: 'start', exclusive: true }
},
start: (_context, match) => {
if (match.event.type !== 'user/message') throw new Error('user-question-reply start requires user/message')
@@ -2,7 +2,9 @@
import { describe, expect, it } from 'vitest'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { SessionSeq } from '@deepseek-ai/dsh-session'
import type { ConversationViewNode } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { messageDefinition } from '../../ui-chat/src/client/conversation-nodes/message.ts'
import { ConversationNodeAssembler } from '../../ui-conversation/src/client/conversation/assembler.ts'
import { en } from '../src/client/locales.ts'
import type { QuestionReplyData } from '../src/client/question-reply.ts'
import { questionReplyDefinition, replyAnswerValues, replyClipboardText, replyPairsOf } from '../src/client/question-reply.ts'
@@ -106,16 +108,33 @@ describe('replyClipboardText', () => {
describe('questionReplyDefinition', () => {
it('claims only appended user messages carrying the reply source', () => {
expect(questionReplyDefinition.match(message(replySource('answered'), answered))).toEqual({ id: 'msg-1', role: 'start' })
expect(questionReplyDefinition.match(message(replySource('answered'), answered)))
.toEqual({ id: 'msg-1', role: 'start', exclusive: true })
expect(questionReplyDefinition.match(message({ kind: 'user' }, answered))).toBeNull()
expect(questionReplyDefinition.match(message({ kind: 'plugin', plugin: 'user-questions' }, answered))).toBeNull()
expect(questionReplyDefinition.match(message(replySource('answered'), answered, null))).toBeNull()
})
it('shares the message id with the generic projection', () => {
it('claims the message the generic projection also matches, so Chat holds one Node for it', () => {
const event = message(replySource('answered'), answered)
expect(messageDefinition.match(event)).toEqual({ id: 'msg-1', role: 'start' })
expect(questionReplyDefinition.match(event)).toEqual(messageDefinition.match(event))
let chat: readonly ConversationViewNode[] = []
const definitions = [messageDefinition, questionReplyDefinition]
const assembler = new ConversationNodeAssembler(
{ entries: () => definitions, fallbackEntry: () => undefined },
{ entries: () => [{
target: 'chat',
create: () => ({
empty: chat,
replace: ({ nodes }) => (chat = nodes),
apply: ({ upserts }) => (chat = [...chat, ...upserts]),
}),
}] },
)
assembler.activateTarget('chat')
assembler.replaceWindow([{ type: 'event', event }], false)
assembler.flush()
expect(chat.map(node => node.kind)).toEqual(['question-reply'])
})
it('projects the outcome from the source and the pairs from the text into its own node', () => {
+1
View File
@@ -71,6 +71,7 @@
"apps/web/tests/plugin-config.e2e.ts",
"apps/web/tests/input-plugin-extensions.e2e.ts",
"apps/web/tests/plugin-manager.e2e.ts",
"apps/web/tests/schedule-header.e2e.ts",
"apps/web/tests/worktree-opt-in.e2e.ts",
"apps/web/tests/session-inspector.e2e.ts",
"apps/web/tests/shared-inspector.e2e.ts",