Merge pull request #4947 from deepseek-harness/fix/chat-groups-followup
fix(chat): 稳定滚动控件与嵌套自动跟随
This commit is contained in:
commit
3889e97de0
90 files changed
+2584
-490
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md
|
||||
2026-09-18-chat-navigation-performance.md: d990c0cb9a351ea3b3224ce5feec912c176a5fc9
|
||||
2026-09-18-chat-navigation-performance.zh.md: 1a330d3b6bf6357199eb09ffc1cdb434cda1c1af
|
||||
2026-09-18-chat-navigation-performance.md: d82c68098fc422abb181ed4e5e1ca41189cd0951
|
||||
2026-09-18-chat-navigation-performance.zh.md: 9c2f81a51ce53660aabf2dd1e729d02e8028b7b6
|
||||
@@ -22,6 +22,8 @@ The rail uses ResizeObserver-delivered viewport dimensions and fixed item sizes.
|
||||
|
||||
[ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) owns follow-tail policy, semantic restoration, and sampled reader movement. [ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) owns loaded or unloaded jumps, paging anchors, replacement tasks, and fallback. [useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) supplies committed inputs and coordinates these owners. Timer and animation-frame completion invoke the owners directly instead of incrementing React tick state. The [pinned-delivery rule](../../../../packages/client/ui-chat/README.md#scroll-ownership) remains independently necessary: small reader gestures cannot be erased by intervening layout growth.
|
||||
|
||||
Shared nested-follow mechanics and control placement are recorded in the [Chat scroll and footer decision](../bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.md); sampled outer reading and historical navigation retain the owners above.
|
||||
|
||||
### One publication per historical jump
|
||||
|
||||
[Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) retains accepted pages and a private paging cursor until its shared target is covered or loading ends. It reverses the page list and flattens it once, then publishes one ordered prepend. Live events continue through their normal publication path. A later page failure publishes the successful prefix once; stream or window replacement discards obsolete buffered pages. Single-page `loadOlder` remains immediate, and repeated jump requests retain the existing lowest-target policy.
|
||||
|
||||
@@ -22,6 +22,8 @@ Status: implemented
|
||||
|
||||
[ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) 负责跟随底部、语义恢复和读者移动采样。[ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) 负责已加载或未加载轮次跳转、分页锚点、任务替换和回退。[useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) 提供已提交输入并协调各对象。定时器与动画帧完成时直接调用负责对象,不再递增 React tick 状态。[贴底事件规则](../../../../packages/client/ui-chat/README.zh.md#scroll-ownership) 仍有独立必要性:期间发生的布局增长不能抵消读者的小幅操作。
|
||||
|
||||
共用的嵌套跟随机制与控件定位见 [Chat 滚动与页脚决策](../bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md);外层采样阅读与历史导航仍由上述对象负责。
|
||||
|
||||
### 每次历史跳转统一发布
|
||||
|
||||
[Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) 在共享目标被覆盖或加载结束前,保留已接受的页面和私有分页游标。它只反转一次页面列表并展开一次,然后发布一次有序前插。实时事件继续走正常发布路径。后续页面失败时,已成功取得的部分统一发布一次;流或窗口被替换时丢弃失效的暂存页面。单页 `loadOlder` 仍立即发布,重复跳转请求保留既有的最低目标策略。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md
|
||||
2026-09-21-conversation-build-groups.md: 7c02de8dd0aaf842690806ea67ddaf5d4561fc4e
|
||||
2026-09-21-conversation-build-groups.zh.md: 4f2b331596cacdd63d902c9db73931d65ac589ad
|
||||
2026-09-21-conversation-build-groups.md: bccb48b0e40a076bbcb59aaca9e9ef6208e9469d
|
||||
2026-09-21-conversation-build-groups.zh.md: 5d2ec9e684c75b3db3568c4f046129431295f736
|
||||
@@ -35,6 +35,7 @@ The grouping foundation provides a separate, Node-input `ConversationGroupDefini
|
||||
| Record | Relationship |
|
||||
|---|---|
|
||||
| [Business Node assembly](2026-08-09-client-conversation-node-assembly.md) | Retains event matching, Contexts, Locations, one business Node per Context, and target Builders; grouping adds another input category. |
|
||||
| [Chat scroll and footer](../bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.md) | Owns clipping and independent nested following without changing Group Definition membership. |
|
||||
|
||||
Grouping preserves the independent Node-assembly decisions. Cross-View navigation and `toolCallFocus` remain separate responsibilities; Group references carry no View handles or resource-navigation policy.
|
||||
|
||||
@@ -106,7 +107,7 @@ Grouping never replays raw events. Node Definitions handle replay first; groupin
|
||||
| [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | Atomic reference validation, keyed sources, array reuse, and local publication. |
|
||||
| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | Record projected Node deltas, target positions, and changed Turn orders; provide indexed readers and defer source notification. No process grouping class. |
|
||||
| [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | Read optional root entries and switch between node/group; fallback to existing Node order. |
|
||||
| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | Stable group parent, member-only subscription, nested Node seats. |
|
||||
| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | Stable group parent, group-local subscriptions, nested Node seats. |
|
||||
| [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | Existing Node sources/renderers, groupPart forwarding, distinct part anchors, Store-replacement rebind. |
|
||||
| [slots.ts](../../../../packages/client/ui-chat/src/client/contract/slots.ts) and [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts) | Existing keyed-hook injection for Group sources; no Slot-engine change. |
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat
|
||||
| 记录 | 关系 |
|
||||
|---|---|
|
||||
| [业务节点组装](2026-08-09-client-conversation-node-assembly.zh.md) | 保留事件匹配、Context、Location、每个 Context 一个业务 Node 及目标 Builder,分组增加另一种输入类别。 |
|
||||
| [Chat 滚动与页脚](../bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md) | 负责裁剪与独立的嵌套跟随,不改变 Group Definition 成员关系。 |
|
||||
|
||||
分组保留独立的节点组装决策。跨 View 导航与 `toolCallFocus` 仍是独立职责,Group 引用不携带 View 句柄或资源导航策略。
|
||||
|
||||
@@ -106,7 +107,7 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价
|
||||
| [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | 原子引用校验、按键来源、数组复用及局部发布。 |
|
||||
| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | 记录投影后节点增量、目标位置及变化轮次顺序,提供索引读取器并推迟来源通知,不持有过程分组类。 |
|
||||
| [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | 读取可选根引用并切换 node/group,无分组时使用既有 Node 顺序。 |
|
||||
| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | 稳定组父级、仅成员订阅及嵌套 Node 容器。 |
|
||||
| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | 稳定组父级、组内订阅及嵌套 Node 容器。 |
|
||||
| [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | 既有 Node 来源及渲染器、groupPart 传递、独立部分锚点及 Store 替换时重绑定。 |
|
||||
| [slots.ts](../../../../packages/client/ui-chat/src/client/contract/slots.ts) 与 [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts) | 使用现有按键钩子注入 Group 来源,不改 Slot 引擎。 |
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md
|
||||
2026-08-17-durable-web-queue-recovery.md: 5fbfdf0e62c1034fbea138016cc2979489de474f
|
||||
2026-08-17-durable-web-queue-recovery.zh.md: 2aaffd6bc571aab449d9429758fdf45bb4dd8d4c
|
||||
2026-08-17-durable-web-queue-recovery.md: 0f18cd7a2b9491d6a8bdfa960409d97a5338f95d
|
||||
2026-08-17-durable-web-queue-recovery.zh.md: 476c1b2fe88156ba8c34e281ff1d1e5663c7913c
|
||||
@@ -49,3 +49,5 @@ Client coverage pins generic Inbox projection delivery, reconnect invalidation f
|
||||
Pending Queue and steering input recover after Host process restart without resuming an Agent. Live Inbox reads, cold history, reconnect, and projection caching use the same domain-owned fold and registry state. Operations on a restored row do resume its ordinary Agent, preserving preset composition and ownership checks.
|
||||
|
||||
Clients receive the raw two-list Inbox value and decide which messages their surface presents. The projection state version invalidates cached rows whenever its serialized state or fold semantics change.
|
||||
|
||||
The [input echo admission decision](2026-09-22-input-echo-admission-ownership.md) owns local Chat/Dock handoff and suppression of delayed Inbox rows without replacing this recovery model.
|
||||
@@ -49,3 +49,5 @@ Host 投影覆盖会读取包含待处理输入的已分离持久 Session,在
|
||||
待处理 Queue 与 steering 输入可在 Host 进程重启后恢复,而无需恢复 Agent。live Inbox 读取、冷历史、重连与投影缓存使用同一份领域拥有的折叠与注册表状态。操作恢复出的行时会恢复其普通 Agent,从而保留 preset 组合与所有权检查。
|
||||
|
||||
客户端接收原始的两列表 Inbox 值,并自行决定界面呈现哪些消息。投影的状态版本会在其序列化状态或折叠语义变化时使缓存行失效。
|
||||
|
||||
[输入回显入档决策](2026-09-22-input-echo-admission-ownership.zh.md)负责本地 Chat/Dock 接管和迟到 Inbox 行的排除,不替换这里的恢复模型。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.md
|
||||
2026-09-22-chat-scroll-follow-and-footer-geometry.md: 56cd1681eef23162df2595a138545ade25e34212
|
||||
2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md: 9979688d52373ae7a64a3f4fc22b932b43091b34
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# Agent Note: Separate Chat clipping and independent follow intent
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Transcript overflow must remain clipped without trapping floating controls or changing the scroll ancestor of sticky content. An `overflow-y: hidden` ancestor becomes the nearest scroll container even when readers scroll a different outer element. It prevents Markdown code banners and expanded compaction headers from sticking to the visible conversation scrollport; floating controls inside that ancestor also use transcript geometry instead of viewport geometry.
|
||||
|
||||
A height-capped process group also has a separate reading position. Outer bottom-follow cannot expose newly appended content inside a group whose own scroll offset stays unchanged. Treating every non-bottom scroll event as reader input does not support native smooth following: the browser emits those same events while an animation approaches its target.
|
||||
|
||||
## Decision
|
||||
|
||||
The transcript, floating controls, and scroll intent retain separate ownership. The [scroll-state table](../../../../packages/client/ui-chat/README.md#scroll-ownership) and [group display rules](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.md#display-modes) define the current product behavior.
|
||||
|
||||
| Area | Implementation | Reason |
|
||||
|---|---|---|
|
||||
| Transcript clipping | `ChatView` uses `overflow-x: visible; overflow-y: clip` on `.root` inside an unclipped frame. | Constrain vertical overflow without introducing a scroll container between sticky content and its actual scrollport. |
|
||||
| Floating controls | TurnNavigator and the back-to-bottom button are siblings of the clipped transcript. | Shared Conversations use the outer scrollport for sticky positioning; standalone Chat uses its frame for absolute positioning. |
|
||||
| Follow intent | `useScrollFollow` retains one independent controller per scrollport. | Reuse thresholds, position sampling, and native scrolling without sharing one follow flag. |
|
||||
| Group lifecycle | `useProcessScroll` owns its existing body/content observer, directional fades, and one-shot opening position. | Group growth remains observable after the body reaches its cap; opening performs no immediate geometry read. |
|
||||
|
||||
The clipped root owns no scroll offset. Shared Conversation scrolling, standalone Chat scrolling, capped process groups, and terminal sections retain their existing scrollports. The horizontal axis stays `visible`: pairing vertical `clip` with horizontal `auto` or `hidden` computes the vertical overflow back to `hidden` and restores the unwanted scroll container.
|
||||
|
||||
An unclosed group opens at its bottom; a closed group opens at its top. The group’s `closed` value, not the whole Turn’s status or an all-node search, decides that initial position. A later close does not reset an already-open reader. Browser find keeps its own reveal position, and display-mode changes preserve mounted members.
|
||||
|
||||
Native smooth growth keeps an outstanding target separate from the current position. Animation progress retains follow intent. Input interrupts native motion before the browser applies reader movement; the resulting actual displacement determines whether following stops or resumes. Further growth retains the issued target until `scrollend`. Arrival at that target, including shrink clamping, allows one request to the latest floor; an off-target stop releases following so focus and find keep their landing. Reduced motion and explicit opening positions use immediate scrolling. Outer following keeps its existing immediate behavior and sampling policy.
|
||||
|
||||
The back-to-bottom button depends only on outer follow intent and restores only that intent. Inner scrolling affects the outer controller only when scroll chaining actually moves the outer scrollport. Group height growth can resize the outer transcript, but the controllers do not invoke each other’s follow actions. Paging and semantic restoration remain with the existing outer reading/navigation owners.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Remove transcript clipping.** That would abandon the overflow constraint. Vertical `clip` retains clipping without creating a scroll container; the unclipped sibling frame keeps floating controls outside the clipped area without a Portal or document-level coordinate synchronization.
|
||||
|
||||
**Keep `overflow-y: hidden` and move only floating controls.** That protects the controls but leaves sticky content inside the unwanted scroll container. Changing the root to `clip` also preserves the scroll ancestor of code and compaction headers without moving those headers out of their content.
|
||||
|
||||
**Share one follow flag, or duplicate the complete controllers.** One flag would pull a group away from a reader merely because the outer transcript follows. Two complete implementations would duplicate animation attribution and threshold rules. Sharing the small controller retains independent state without moving paging, Turn navigation, or semantic memory into it.
|
||||
|
||||
**Only replace `scrollTop` assignment with smooth `scrollTo`.** Intermediate animation offsets would appear to be reader movement and disable following. Native motion needs an explicit target and input interruption; a custom animation-frame interpolator is unnecessary.
|
||||
|
||||
## Verification
|
||||
|
||||
[Controller tests](../../../../packages/client/ui-chat/tests/scroll-follow.client.spec.ts) cover independent thresholds, animation progress, target reuse, input interruption, reduced motion, and immediate positioning. [Chat component tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) cover group opening, pause/resume, closing while open, and browser-find preservation. [Viewport tests](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts) retain the outer anchor and attribution behavior.
|
||||
|
||||
[Browser scroll cases](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) exercise floating-control geometry, nested following, paging, streaming, and real wheel input through the shipped Web composition.
|
||||
|
||||
[Seeded-history cases](../../../../apps/web/tests/seeded-history.e2e.ts) check an expanded compaction header and its nested code banner against the actual conversation scrollport, including their stacking and copy-button access.
|
||||
|
||||
## Consequences
|
||||
|
||||
The shared controller stores intent without scheduling React renders; callers still publish visible state changes, and group edge fades update only when their booleans change. Follow operations read the affected scrollport, not its Node collection. Native scrolling still performs browser layout and paint work. These changes establish behavior and ownership, not a measured latency or memory improvement.
|
||||
|
||||
The [navigation decision](../architecture/2026-09-18-chat-navigation-performance.md) still owns sampled reading, known landings, and batched historical jumps. The [grouping decision](../architecture/2026-09-21-conversation-build-groups.md) still owns Definition-based membership and stable rendering identities. Extracting scroll-follow mechanics does not supersede either decision.
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# Agent Note: 分离 Chat 裁剪与独立滚动跟随意图
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-22-chat-scroll-follow-and-footer-geometry.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
正文溢出必须保持裁剪,同时不能限制悬浮控件或改变 sticky 内容的滚动祖先。即使读者实际滚动的是外层元素,`overflow-y: hidden` 祖先也会成为最近的滚动容器。这会阻止 Markdown 代码块头栏和展开的压缩摘要头栏吸附在可见会话滚动区域;位于该祖先内的悬浮控件也会参照正文几何,而非视口几何。
|
||||
|
||||
限高过程组也有独立阅读位置。若组内偏移不变,外层跟随底部无法展示组内新增内容。把每个未到底的滚动事件都当成读者输入,也无法支持原生平滑跟随:浏览器在动画接近目标期间同样会发出这些事件。
|
||||
|
||||
## 决策
|
||||
|
||||
正文、悬浮控件及滚动意图分别由独立对象负责。[滚动状态表](../../../../packages/client/ui-chat/README.zh.md#scroll-ownership)与[分组展示规则](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.zh.md#display-modes)定义当前产品行为。
|
||||
|
||||
| 范围 | 实现 | 原因 |
|
||||
|---|---|---|
|
||||
| 正文裁剪 | `ChatView` 在无裁剪的外框内,对 `.root` 使用 `overflow-x: visible; overflow-y: clip`。 | 限制纵向溢出,不在 sticky 内容与其实际滚动区域之间引入滚动容器。 |
|
||||
| 悬浮控件 | TurnNavigator 与回到底部按钮位于正文裁剪层的同级。 | 共享 Conversation 相对外层滚动容器做 sticky 定位;独立 Chat 相对自身外框做 absolute 定位。 |
|
||||
| 跟随意图 | `useScrollFollow` 为每个滚动区保留独立控制器。 | 共用阈值、位置采样及原生滚动,不共用一个跟随开关。 |
|
||||
| 小组生命周期 | `useProcessScroll` 负责既有的正文与内容观察器、方向渐隐和一次性展开位置。 | 正文达到限高后,内容增长仍可被观察;展开时不立即读取几何。 |
|
||||
|
||||
被裁剪的根节点不持有滚动偏移。共享 Conversation、独立 Chat、限高过程组和终端区域保留各自原有的滚动容器。横轴保持 `visible`:纵向 `clip` 如果与横向 `auto` 或 `hidden` 组合,纵向溢出的计算值会变回 `hidden`,重新引入不需要的滚动容器。
|
||||
|
||||
未关闭的小组展开到自己的底部,已关闭的小组展开到顶部。初始位置由组自身的 `closed` 决定,不取整个 Turn 的状态,也不遍历全部节点。组随后关闭不会重置已展开的阅读位置。浏览器查找保留自身的展开定位,模式切换保留已挂载成员。
|
||||
|
||||
原生平滑跟随将未完成的目标与当前位置分开记录。动画进度保留跟随意图。输入在浏览器处理读者移动前中断原生动画,再由实际位移决定是否暂停或恢复跟随。后续增长保留已发出的目标,直到 `scrollend`。到达该目标(包括内容缩短后的钳制位置)才允许再次请求最新底部;停在目标之外则释放跟随,让焦点和查找保留落点。减少动态效果与明确的展开定位使用即时滚动。外层跟随保留既有的即时行为和采样策略。
|
||||
|
||||
回到底部按钮只取决于外层跟随意图,也只恢复该意图。只有滚动传递确实移动外层滚动区域时,内层滚动才影响外层控制器。小组长高可以改变外层正文尺寸,但两个控制器不会互相调用跟随操作。分页和语义恢复仍由既有的外层阅读与导航对象负责。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**删除正文裁剪。** 这会放弃溢出约束。纵向 `clip` 保留裁剪而不创建滚动容器;无裁剪的同级外框让悬浮控件位于裁剪区域之外,不需要 Portal 或文档级坐标同步。
|
||||
|
||||
**保留 `overflow-y: hidden`,仅移动悬浮控件。** 这可以保护控件,但 sticky 内容仍位于不需要的滚动容器内。将根节点改为 `clip` 也能保留代码和压缩摘要头栏的滚动祖先,无需把这些头栏移出其正文。
|
||||
|
||||
**共用一个跟随开关,或复制完整控制器。** 共用开关会仅因外层跟随就把小组拉离读者位置。复制两套完整实现则会重复动画归因和阈值规则。共用小控制器可以保留独立状态,同时不把分页、轮次导航及语义记忆搬入其中。
|
||||
|
||||
**只把 `scrollTop` 赋值换成平滑 `scrollTo`。** 动画中间偏移会被误判为读者移动,导致跟随关闭。原生动画需要明确的目标与输入中断处理,无需自写动画帧插值器。
|
||||
|
||||
## 验证
|
||||
|
||||
[控制器测试](../../../../packages/client/ui-chat/tests/scroll-follow.client.spec.ts)覆盖独立阈值、动画进度、目标复用、输入中断、减少动态效果和即时定位。[Chat 组件测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx)覆盖小组展开、暂停与恢复、展开期间关闭及浏览器查找位置保持。[视口测试](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts)保留外层锚点与归因行为。
|
||||
|
||||
[浏览器滚动用例](../../../../apps/web/tests/chat-scroll-contract.e2e.ts)通过正式 Web 装配验证悬浮控件几何、嵌套跟随、分页、流式内容与真实滚轮输入。
|
||||
|
||||
[历史回放用例](../../../../apps/web/tests/seeded-history.e2e.ts)相对实际会话滚动区域检查展开的压缩摘要头栏及其内部代码头栏,包括层叠关系和复制按钮的可访问性。
|
||||
|
||||
## 影响
|
||||
|
||||
共享控制器只保存意图,不自行调度 React 渲染;调用方仍发布可见状态变化,小组边缘渐隐仅在布尔值变化时更新。跟随操作读取受影响的滚动容器,而非节点集合。原生滚动仍有浏览器布局和绘制成本。这些改动确立行为与职责,不代表已测得延迟或内存收益。
|
||||
|
||||
[导航决策](../architecture/2026-09-18-chat-navigation-performance.zh.md)继续负责采样阅读、已知落点与聚合历史跳转。[分组决策](../architecture/2026-09-21-conversation-build-groups.zh.md)继续负责 Definition 成员关系与稳定渲染身份。提取滚动跟随机制不取代这两项决策。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.md
|
||||
2026-09-22-input-echo-admission-ownership.md: 227857bd8168aa1d7f51b621737c2ed0ab572632
|
||||
2026-09-22-input-echo-admission-ownership.zh.md: 1d64de220a3c6d5c9357770175c79266b8f9f063
|
||||
@@ -0,0 +1,54 @@
|
||||
# Agent Note: Input echoes retain display ownership through admission
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-22-input-echo-admission-ownership.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
An idle submission can appear first as a local Chat echo, then as an Inbox row in QueueDock, and finally as a durable user message. Inbox projection and conversation history arrive independently: retiring the echo on acceptance leaves a gap before admission, while a delayed Inbox projection can duplicate an already-admitted message. Separately, turn/start precedes user/message, so an empty progress title can appear before the echo and exchange positions with it when the real input arrives.
|
||||
|
||||
These transitions concern display ownership, not scrolling or composer geometry. A delay cannot identify which representation is authoritative, and moving every echo before the progress title would move running steering ahead of existing work.
|
||||
|
||||
## Decision
|
||||
|
||||
- A submission keeps its locally selected placement: idle input stays in Chat, running steering stays at the process tail, and explicitly queued input stays in QueueDock. Inbox acceptance does not change that placement.
|
||||
- Chat hides an echo by matching the durable input's rpcId in the same render, and excludes stale next-step Inbox rows matched by admitted local Chat submissions. QueueDock excludes only Inbox rows matched by local transcript echoes; unrelated queued messages remain visible and actionable.
|
||||
- Session retains an admitted transcript or steering submission's local suppression identity until the Inbox projection watermark reaches its claim sequence. Higher-sequence projection retention then prevents an older accepted row from reappearing after that identity retires. Claim tracking and cancellation use the existing per-submission receipt for both Inbox targets; no new SessionSnapshot field, Host event, or persisted format is needed.
|
||||
- At an open, input-free progress control at the end of Chat, the first transcript echo precedes the control. Other echoes remain at the tail. Last-input Turn tracking shares the existing rpcId scan; it does not add a history traversal or predict a sequence of future Turns.
|
||||
- Pending bubbles and durable seats share one keyed React list. Inserting a progress title preserves the pending bubble's mounted identity. Once admitted, the durable node owns ordering and ordinary Group segmentation.
|
||||
|
||||
The [durable Inbox recovery decision](2026-08-17-durable-web-queue-recovery.md) still owns cold reads, reconstruction and projection transport. The [generic file upload decision](../feature/2026-08-26-generic-file-upload.md) still owns attachment preparation and draft recovery; retirement returns each submission's durable references once, in attachment order.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Delay QueueDock or echo removal by a fixed interval.** CPU throttling, transport batching and projection ordering can exceed any such interval. Time does not establish admission or confirm that stale queue state is gone.
|
||||
|
||||
**Retain the echo without changing the other readers.** Chat-side Inbox deduplication can still remove the echo, and QueueDock can still display it. The Session lifetime, Chat handoff and Dock exclusion must agree.
|
||||
|
||||
**Delete the suppression identity as soon as user/message arrives.** A later Inbox acceptance projection can resurrect a Dock or pending-steering row. The claim watermark supplies the required ordering evidence without assuming the streams publish together.
|
||||
|
||||
**Move all pending inputs before the newest Turn control.** Running steering belongs after existing process content, and several ordinary submissions can target different Turns. Only the first local transcript echo receives the empty-control placement adjustment.
|
||||
|
||||
**Add a Host correlation event or a client-side Turn scheduler.** Exact prediction across competing clients and reordered requests would widen the change beyond local optimistic presentation. Durable admission remains authoritative; optimistic ordering is deliberately approximate in those races.
|
||||
|
||||
## Verification
|
||||
|
||||
| Evidence | Covered behavior |
|
||||
|---|---|
|
||||
| [Session submissions](../../../../packages/api/session-controller/tests/session-pending-submissions.client.spec.ts) | Projection-first and history-first handoff, claim watermark, overlapping opening/queue/steering identities, attachment callbacks, cancellation, unadmitted endings, reconnect baselines and disposal. |
|
||||
| [Projection store](../../../../packages/api/session-controller/tests/projection-store.client.spec.ts) | Missing/cached watermarks, monotonic sequence retention and clearing. |
|
||||
| [Chat rendering](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) | Same-render rpcId replacement, stable echo mounting, title-before-input delivery in all three modes, steering remaining behind the title, historical empty Turns, and six local-versus-Host orderings. |
|
||||
| [QueueDock](../../../../packages/client/ui-conversation/tests/queue-dock.client.spec.tsx) | Local transcript exclusion, unrelated rows/actions, and all six three-message acceptance orders followed by FIFO claims. |
|
||||
| [Recorded Web replay](../../../../apps/web/tests/idle-submission-handoff.e2e.ts) | Real Web composition with FIFO-preserving delivery barriers: both Inbox/history arrival orders at 6× CPU throttling, an explicit turn/start-to-user/message gap, and complete three-Turn handoff for all six request orders. |
|
||||
| [Steering Web replay](../../../../apps/web/tests/steering.e2e.ts) | Local shortcut steering remains unique when durable admission precedes its Inbox claim projection. |
|
||||
|
||||
The single-input regression fails before the fix: Inbox acceptance moves the bubble into Dock, and a separately delivered Turn title precedes the echo. The corrected path retains one Chat representation at a stable vertical position. Multi-input replay verifies durable order and absence of duplicate or residual echoes; it does not assert that optimistic ordering predicts Host order.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Steering without a locally tracked submission still follows the Inbox projection; a delayed projection can briefly duplicate its durable row. Local deduplication adds no history scan for other clients' submissions.
|
||||
- Reconnect may discard receipt-confirmed optimistic echoes and display the new authoritative baseline. A claimed input outside that baseline can briefly have no bubble; reconnect does not promise DOM continuity.
|
||||
- Submissions made before the local running update can all remain transcript echoes. If A, B and C are submitted locally in that order but B or C enters first, durable admission changes their visible order. Even when A enters first, remaining local echoes can differ from the Host queue order.
|
||||
- Cross-client competition and request-preparation races can change the actual opening input. The client does not migrate an idle-classified echo into Dock merely because the Host queued it behind another Turn.
|
||||
- The changes prevent representation handoff flicker on the ordinary online path without changing model input, Inbox scheduling, scroll-follow policy, or released Session data. The browser barriers establish behavior, not a general latency or throughput improvement.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Agent Note: 输入回显在入档期间保持展示归属
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-22-input-echo-admission-ownership.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
空闲时提交的输入可能先显示为 Chat 本地回显,再变为 QueueDock 的 Inbox 行,最后才显示为持久用户消息。Inbox 投影与会话历史独立到达:受理时回收回显会在入档前留下空档,迟到的 Inbox 投影又可能重复显示已经入档的消息。此外,turn/start 先于 user/message,因此空过程标题可能先出现在回显前面,真实输入到达时再与它交换位置。
|
||||
|
||||
这些转换涉及展示归属,而非滚动或 composer 几何。延迟不能判断哪种表示已经成为权威,将所有回显移到过程标题前又会把运行中的 steering 移到已有工作之前。
|
||||
|
||||
## 决策
|
||||
|
||||
- 提交保留本地发送时选择的 placement:空闲输入留在 Chat,运行中的 steering 留在过程末尾,明确排队的输入留在 QueueDock。Inbox 受理不改变展示归属。
|
||||
- Chat 在同一次渲染中按持久输入的 rpcId 隐藏回显,并排除匹配已入档本地 Chat 提交的旧 next-step Inbox 行。QueueDock 只排除匹配本地 transcript 回显的 Inbox 行;其他排队消息仍可见且可操作。
|
||||
- Session 保留已入档 transcript 或 steering 提交的本地排除身份,直到 Inbox 投影水位达到其领取序号。随后,较高序号投影优先的规则阻止旧受理行在该身份回收后重新出现。领取跟踪和取消共用已有逐提交 receipt,并覆盖两种 Inbox target;不新增 SessionSnapshot 字段、Host 事件或持久格式。
|
||||
- Chat 末尾为进行中且没有真实输入的过程控制行时,第一条 transcript 回显显示在控制行前,其他回显留在末尾。最后输入的轮次信息复用现有 rpcId 扫描,不新增历史遍历,也不预测未来轮次序列。
|
||||
- 待处理气泡与持久节点容器共用一个 keyed React 列表。插入过程标题保留回显气泡的挂载身份。入档后,由持久节点决定排序及普通 Group 切分。
|
||||
|
||||
[持久 Inbox 恢复决策](2026-08-17-durable-web-queue-recovery.zh.md)仍负责冷读取、重建与投影传输。[通用文件上传决策](../feature/2026-08-26-generic-file-upload.zh.md)仍负责附件准备及草稿恢复;回收按附件顺序返回各提交的持久引用,且只回调一次。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**固定延迟 QueueDock 或回显回收。** CPU 降速、传输批处理与投影到达顺序都可能超过该间隔。时间不能证明入档,也不能确认过期队列状态已经消失。
|
||||
|
||||
**只延长回显生命周期,不调整其他读取方。** Chat 侧按 Inbox 去重仍会移除回显,QueueDock 仍可能展示同一消息。Session 生命周期、Chat 接管和 Dock 排除必须一致。
|
||||
|
||||
**user/message 到达后立即删除排除身份。** 较晚的 Inbox 受理投影会复活 Dock 行或待处理 steering 行。领取水位提供必要的排序依据,不要求两条流同时发布。
|
||||
|
||||
**把全部待处理输入移到最新 Turn 控制行前。** 运行中的 steering 属于已有过程内容之后,多条普通提交也可能属于不同轮次。只有第一条本地 transcript 回显参与空控制行前的定位调整。
|
||||
|
||||
**新增 Host 关联事件或客户端 Turn 调度器。** 精确预测跨客户端竞争和请求乱序会超出本地乐观展示的范围。持久入档仍是权威;这些竞争场景中的乐观排序有意保持近似。
|
||||
|
||||
## 验证
|
||||
|
||||
| 证据 | 覆盖行为 |
|
||||
|---|---|
|
||||
| [Session 提交](../../../../packages/api/session-controller/tests/session-pending-submissions.client.spec.ts) | 投影先到与历史先到的接管、领取水位、开场/排队/steering 身份交错、附件回调、取消、未入档结束、重连基线及销毁。 |
|
||||
| [投影存储](../../../../packages/api/session-controller/tests/projection-store.client.spec.ts) | 缺失或缓存水位、单调序号保留及清理。 |
|
||||
| [Chat 渲染](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) | 同帧 rpcId 替换、回显稳定挂载、三种模式下标题先于输入到达、steering 留在标题之后、历史空轮次及六种本地与 Host 排列。 |
|
||||
| [QueueDock](../../../../packages/client/ui-conversation/tests/queue-dock.client.spec.tsx) | 本地 transcript 排除、其他行及操作、三条消息的全部六种受理顺序及后续 FIFO 领取。 |
|
||||
| [录制 Web 回放](../../../../apps/web/tests/idle-submission-handoff.e2e.ts) | 真实 Web 装配配合保留 FIFO 的投递屏障:6× CPU 降速下的 Inbox/历史两种到达顺序、明确的 turn/start 至 user/message 空档,以及六种请求排列的完整三轮接管。 |
|
||||
| [Steering Web 回放](../../../../apps/web/tests/steering.e2e.ts) | 本地快捷键 steering 的持久入档先于 Inbox 领取投影到达时,仍只显示一份。 |
|
||||
|
||||
单条输入回归在修复前失败:Inbox 受理将气泡移入 Dock,单独交付的 Turn 标题又会出现在回显之前。修复后的路径保留唯一的 Chat 表示,且纵向位置稳定。多输入回放验证持久顺序及无重复、无残留回显,不断言乐观排列可以预测 Host 顺序。
|
||||
|
||||
## 后果
|
||||
|
||||
- 没有本地提交身份的 steering 仍按 Inbox 投影显示;投影迟到时可能短暂与持久节点重复。本地去重不为其他客户端的提交增加历史扫描。
|
||||
- 重连可撤销已有接收回执的乐观回显,并展示新的权威基线。已领取但不在该基线中的输入可能短暂无气泡;重连不保证 DOM 连续性。
|
||||
- 本地 running 更新前连续提交的消息可能都留为 transcript 回显。若本地按 A、B、C 提交,但 B 或 C 先入档,持久接管会调整可见顺序。即使 A 先入档,其余本地回显也可能与 Host 队列顺序不同。
|
||||
- 跨客户端竞争和请求准备的先后差异可能改变实际开场输入。客户端不会仅因 Host 将输入排在另一轮后面,就把已按空闲分类的回显迁入 Dock。
|
||||
- 这些改动消除普通在线路径中表示切换带来的抖动,不改变模型输入、Inbox 调度、滚动跟随规则或已发布 Session 数据。浏览器屏障证明行为,不代表普遍的延迟或吞吐提升。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md
|
||||
2026-08-26-generic-file-upload.md: ddaf92a396ade59d65a81300531be9bce2aeeaba
|
||||
2026-08-26-generic-file-upload.zh.md: 27a73536ec3632f40ee49a59d932406e77b5591d
|
||||
2026-08-26-generic-file-upload.md: 196d048da9ffc1342cd62cb9c25674b7cecaffac
|
||||
2026-08-26-generic-file-upload.zh.md: fce7cef76cf36060f55165d009c568480abb0f16
|
||||
@@ -52,3 +52,5 @@ Unit and integration coverage pins verbatim and streamed storage, name sanitizat
|
||||
## Related
|
||||
|
||||
The [Trajectory attachment presentation decision](../bug-fix/2026-09-15-trajectory-attachment-presentation.md) owns ledger counts and inspector attachment layouts.
|
||||
|
||||
The [input echo admission decision](../bug-fix/2026-09-22-input-echo-admission-ownership.md) owns placement-specific retirement and delayed Inbox suppression for submissions with or without attachments.
|
||||
@@ -52,3 +52,5 @@ Status: implemented
|
||||
## 相关记录
|
||||
|
||||
[Trajectory 附件展示决定](../bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md)负责列表计数与详情面板附件布局。
|
||||
|
||||
[输入回显入档决策](../bug-fix/2026-09-22-input-echo-admission-ownership.zh.md)负责有无附件提交的按展示归属回收和迟到 Inbox 排除。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/web/tests/README.md
|
||||
README.md: 8b9c58ce9e4cf2f5f6978c0c6eb5517fe000d3d2
|
||||
README.zh.md: 199ae3b56166aa2ffa1c0bed9a16b844ffd7b74f
|
||||
README.md: 2744c40c7ff6d90e2d1d6cefadfcf6b55acb7e1b
|
||||
README.zh.md: e7dd1a23b96e5b3eff055e49deec8c86d8e91727
|
||||
@@ -10,6 +10,8 @@ Ordinary scenarios begin with no registered Workspace or Session and a durable m
|
||||
|
||||
State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. See the [CI fixture synchronization decision](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md).
|
||||
|
||||
Explicit scrolling uses `scrollIntoView` from `support.ts`: it resolves the locator again when its old element detaches and checks connection in the same browser task as native scrolling. Scenarios retain their visibility and geometry assertions after scrolling.
|
||||
|
||||
## These are Host-face tests
|
||||
|
||||
They type-check in the root `tsconfig.host.json`, not in the Client aggregate, because they read Host services directly: `ctx.connection`, the Host `SessionStore`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not make a file part of the Client program — the two faces merge Cordis `Context` under the same keys with different services, so one program cannot see both. Moving these files into the Client aggregate makes every Host-service access fail to compile.
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。参见 [CI fixture 同步决策](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md)。
|
||||
|
||||
显式滚动使用 `support.ts` 的 `scrollIntoView`:旧元素脱离 DOM 时重新解析 locator,并在同一个浏览器任务中检查连接状态、执行原生滚动。各场景保留滚动后的可见性与几何断言。
|
||||
|
||||
## 这些是 Host 面的测试
|
||||
|
||||
它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取 Host 服务:`ctx.connection`、Host 侧 `SessionStore` 与 `ctx.sessionProjectionCache`。运行时驱动浏览器并不使一个文件成为 Client 程序的一部分——两个 face 在相同的键上以不同服务合并 Cordis `Context`,因此单个程序无法同时看见两者。把这些文件挪进 Client aggregate 会让每一处 Host 服务访问都无法编译。
|
||||
|
||||
@@ -524,6 +524,75 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
await browser?.close()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps floating controls anchored outside the clipped transcript', async () => {
|
||||
await withScrollWorld({
|
||||
failureShot: 'web-e2e-chat-floating-controls',
|
||||
seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }],
|
||||
}, async (world) => {
|
||||
await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns))
|
||||
const host = world.page.locator('[data-conversation-scroll]')
|
||||
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
|
||||
const expectControls = async (): Promise<void> => {
|
||||
await expect.poll(() => host.evaluate((element) => {
|
||||
const rail = element.querySelector('nav[aria-label="Turn navigation"]')
|
||||
const button = element.querySelector('button[aria-label="Back to bottom"]')
|
||||
const composer = element.querySelector('[data-composer-seat]')
|
||||
if (rail === null || button === null || composer === null) return Infinity
|
||||
const viewport = element.getBoundingClientRect()
|
||||
const railBox = rail.getBoundingClientRect()
|
||||
const buttonBox = button.getBoundingClientRect()
|
||||
const composerBox = composer.getBoundingClientRect()
|
||||
const top = viewport.top + element.clientTop
|
||||
return Math.max(
|
||||
Math.abs((railBox.top + railBox.bottom) / 2 - (top + composerBox.top) / 2),
|
||||
Math.abs(viewport.left + element.clientLeft + element.clientWidth - railBox.right - 12),
|
||||
Math.abs(composerBox.top - buttonBox.bottom - 16),
|
||||
)
|
||||
}), { timeout: 10_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
}
|
||||
|
||||
await wheelTranscript(world.page, -1_200)
|
||||
await backToBottom.waitFor({ timeout: 10_000 })
|
||||
await expectControls()
|
||||
await wheelTranscript(world.page, -1_200)
|
||||
await expectControls()
|
||||
|
||||
const seat = world.page.locator('[data-composer-seat]')
|
||||
const initialHeight = await seat.evaluate(element => element.getBoundingClientRect().height)
|
||||
const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last()
|
||||
await composer.fill(Array.from({ length: 8 }, (_, index) => `draft line ${index}`).join('\n'))
|
||||
await expect.poll(() => seat.evaluate(element => element.getBoundingClientRect().height))
|
||||
.toBeGreaterThan(initialHeight + 40)
|
||||
await expectControls()
|
||||
await composer.fill('')
|
||||
await expectControls()
|
||||
|
||||
const rail = world.page.getByRole('navigation', { name: 'Turn navigation', includeHidden: true })
|
||||
const frame = rail.locator('../..')
|
||||
const originalStyle = await frame.getAttribute('style')
|
||||
try {
|
||||
for (const clearance of [16, 8]) {
|
||||
for (const contentWidth of [901, 900, 899, 901]) {
|
||||
await frame.evaluate((element, { clearance, contentWidth }) => {
|
||||
element.style.width = `${contentWidth + 2 * (clearance + 16)}px`
|
||||
element.style.setProperty('--dsh-composer-side-clearance', `${clearance}px`)
|
||||
}, { clearance, contentWidth })
|
||||
await expect.poll(() => rail.isVisible()).toBe(contentWidth > 900)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await frame.evaluate((element, style) => {
|
||||
if (style === null) element.removeAttribute('style')
|
||||
else element.setAttribute('style', style)
|
||||
}, originalStyle)
|
||||
}
|
||||
await expectControls()
|
||||
await backToBottom.click()
|
||||
await expectBottom(world.page)
|
||||
assertClean(world)
|
||||
})
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('preserves the reader anchor when history and streaming arrive concurrently', async () => {
|
||||
await withScrollWorld({
|
||||
failureShot: 'web-e2e-chat-scroll-history-stream',
|
||||
@@ -608,6 +677,131 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
})
|
||||
}, 180_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('follows a growing process group independently of the outer transcript', async () => {
|
||||
const parts = [
|
||||
'GROUP_SCROLL_START\n\n',
|
||||
...Array.from({ length: 4 }, (_, batch) => Array.from({ length: 20 }, (_, row) =>
|
||||
`Group batch ${batch} paragraph ${row}: inspect the next recorded operation.\n\n`).join('')),
|
||||
'GROUP_SCROLL_END\n\n',
|
||||
]
|
||||
const chunks: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
...parts.map((text): StreamChunk => ({ type: 'reasoning-delta', index: 0, text })),
|
||||
{ type: 'block-end', index: 0, block: { type: 'reasoning', text: parts.join('') } },
|
||||
{ type: 'block-start', index: 1, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 1, text: 'GROUP_SCROLL_DONE' },
|
||||
{ type: 'block-end', index: 1, block: { type: 'text', text: 'GROUP_SCROLL_DONE' } },
|
||||
{ type: 'usage', usage: { inputTokens: 256, outputTokens: 512 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
await withScrollWorld({
|
||||
failureShot: 'web-e2e-chat-process-follow',
|
||||
replay: [replayEntry(chunks)],
|
||||
seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }],
|
||||
}, async (world) => {
|
||||
await world.page.emulateMedia({ reducedMotion: 'no-preference' })
|
||||
await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns))
|
||||
const gates = parts.slice(1).map(() => Promise.withResolvers<undefined>())
|
||||
const dispose = world.scaffold.ctx.on('llm/stream', async function* (_options, next) {
|
||||
let index = -1
|
||||
for await (const chunk of next()) {
|
||||
if (chunk.type === 'reasoning-delta') await gates[index++]?.promise
|
||||
yield chunk
|
||||
}
|
||||
})
|
||||
const settled = world.scaffold.whenTurnSettled(60_000)
|
||||
try {
|
||||
await world.page.locator('[data-composer-input][contenteditable="true"]').last().fill(
|
||||
'Inspect the recorded operations while I scroll the conversation.',
|
||||
)
|
||||
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
const group = world.page.locator('[data-step-process]:has([data-variant="think"][data-state="running"])')
|
||||
await group.locator('[data-process-activity]').click()
|
||||
await group.locator('[data-disclosure-row]').click()
|
||||
const body = group.locator('[data-step-process-body]')
|
||||
await nextPaint(world.page)
|
||||
expect(await body.evaluate(element => element.scrollHeight <= element.clientHeight)).toBe(true)
|
||||
const height = () => body.evaluate(element => element.scrollHeight)
|
||||
const top = () => body.evaluate(element => element.scrollTop)
|
||||
const expectGroupBottom = async (): Promise<void> => {
|
||||
await expect.poll(() => body.evaluate(element =>
|
||||
element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
}
|
||||
const grow = async (index: number): Promise<void> => {
|
||||
const before = await height()
|
||||
gates[index]!.resolve(undefined)
|
||||
await expect.poll(height).toBeGreaterThan(before + 200)
|
||||
}
|
||||
|
||||
// Outer following is a precondition independent of opening the inner disclosure.
|
||||
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
|
||||
await wheelTranscript(world.page, -400)
|
||||
await backToBottom.click()
|
||||
await expectBottom(world.page)
|
||||
await grow(0)
|
||||
await expectGroupBottom()
|
||||
await expectBottom(world.page)
|
||||
// Per-frame growth exercises the real observer and native animation
|
||||
// independently of transport batching while the next model chunk waits.
|
||||
const content = group.locator('[data-step-process-content]')
|
||||
const heightStyle = await content.evaluate(element => element.style.height)
|
||||
const beforeGrowth = await top()
|
||||
try {
|
||||
const duringGrowth = await body.evaluate(async (scroller) => {
|
||||
const content = scroller.querySelector<HTMLElement>('[data-step-process-content]')!
|
||||
const initialHeight = content.getBoundingClientRect().height
|
||||
let halfway = scroller.scrollTop
|
||||
for (let frame = 1; frame <= 60; frame++) {
|
||||
content.style.height = `${initialHeight + frame * 20}px`
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => { resolve() }))
|
||||
if (frame === 30) halfway = scroller.scrollTop
|
||||
}
|
||||
return halfway
|
||||
})
|
||||
expect(duringGrowth).toBeGreaterThan(beforeGrowth + 20)
|
||||
await expectGroupBottom()
|
||||
} finally {
|
||||
await content.evaluate((element, previous) => { element.style.height = previous }, heightStyle)
|
||||
}
|
||||
await expectGroupBottom()
|
||||
await wheelTranscript(world.page, -800)
|
||||
await backToBottom.waitFor()
|
||||
const outerTop = (await scrollGeometry(world.page)).scrollTop
|
||||
await grow(1)
|
||||
await expectGroupBottom()
|
||||
expect(Math.abs((await scrollGeometry(world.page)).scrollTop - outerTop)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
|
||||
await backToBottom.click()
|
||||
await expectBottom(world.page)
|
||||
const pinnedTop = await top()
|
||||
await body.hover()
|
||||
await world.page.mouse.wheel(0, -160)
|
||||
await expect.poll(top).toBeLessThan(pinnedTop - 100)
|
||||
await nextPaint(world.page)
|
||||
const readerTop = await top()
|
||||
await grow(2)
|
||||
expect(Math.abs(await top() - readerTop)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
await expectBottom(world.page)
|
||||
|
||||
await group.locator('[data-process-activity]').click()
|
||||
await group.locator('[data-process-activity]').click()
|
||||
await expectGroupBottom()
|
||||
|
||||
await body.hover()
|
||||
await world.page.mouse.wheel(0, 10_000)
|
||||
await expectGroupBottom()
|
||||
await grow(3)
|
||||
await expectGroupBottom()
|
||||
assertClean(world)
|
||||
} finally {
|
||||
for (const gate of gates) gate.resolve(undefined)
|
||||
dispose()
|
||||
await settled
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('virtualizes the outline rail and jumps to an unloaded turn', async () => {
|
||||
await withScrollWorld({
|
||||
failureShot: 'web-e2e-turn-rail-jump',
|
||||
|
||||
@@ -14,7 +14,7 @@ import { excelDrawingFixture } from '../../../packages/client/ui-sidebar-documen
|
||||
import { xlsFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts'
|
||||
import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts'
|
||||
import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { openSettings, connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { openSettings, connectFreshWorkspace, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/document-preview', import.meta.url))
|
||||
@@ -396,13 +396,13 @@ else process.exit(1);
|
||||
const markdownImages: string[] = []
|
||||
for (const alt of ['relative image', 'absolute image', 'reference image']) {
|
||||
const image = preview.getByRole('img', { name: alt, exact: true })
|
||||
await image.scrollIntoViewIfNeeded()
|
||||
await scrollIntoView(image)
|
||||
await expect.poll(() => image.evaluate((node: HTMLImageElement) => node.complete && node.naturalWidth > 0)).toBe(true)
|
||||
const source = new URL(await image.getAttribute('src') ?? '')
|
||||
expect(source.pathname).toBe('/api/file')
|
||||
markdownImages.push(alt)
|
||||
}
|
||||
await preview.getByRole('heading', { name: heading, exact: true }).scrollIntoViewIfNeeded()
|
||||
await scrollIntoView(preview.getByRole('heading', { name: heading, exact: true }))
|
||||
await successShot(page, 'markdown')
|
||||
const markdownTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('smoke.md', { exact: true }) })
|
||||
const markdownTabId = await markdownTab.getAttribute('data-dockkit-tab')
|
||||
@@ -567,7 +567,7 @@ else process.exit(1);
|
||||
expect(firstColor).toBe('red')
|
||||
const workerNames = await Promise.all(page.workers().map(worker => worker.evaluate(() => self.name)))
|
||||
expect(workerNames).toContain('dsh-pdf')
|
||||
await preview.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded()
|
||||
await scrollIntoView(preview.locator('[data-pdf-page="2"]'))
|
||||
const secondPage = preview.getByRole('img', { name: 'PDF page 2', exact: true })
|
||||
await secondPage.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue')
|
||||
@@ -580,7 +580,7 @@ else process.exit(1);
|
||||
await filesTab.click()
|
||||
await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
|
||||
await pdfTab.click()
|
||||
await preview.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded()
|
||||
await scrollIntoView(preview.locator('[data-pdf-page="2"]'))
|
||||
await secondPage.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue')
|
||||
const restoredColor = await canvasColor(secondPage)
|
||||
|
||||
@@ -67,11 +67,18 @@ describe('web e2e: goal bar clear convergence', () => {
|
||||
declaredTop: Number.parseFloat(element.style.top),
|
||||
tooltipCenter: tooltip.left + tooltip.width / 2,
|
||||
tooltipTop: tooltip.top,
|
||||
left: tooltip.left,
|
||||
right: tooltip.right,
|
||||
viewportWidth: window.innerWidth,
|
||||
visibility: element.style.visibility,
|
||||
}
|
||||
})
|
||||
expect(tooltipGeometry).not.toBeNull()
|
||||
expect(Math.abs(tooltipGeometry!.tooltipCenter - tooltipGeometry!.declaredLeft)).toBeLessThan(2)
|
||||
expect(Math.abs(tooltipGeometry!.tooltipTop - tooltipGeometry!.declaredTop)).toBeLessThan(2)
|
||||
expect(tooltipGeometry!.visibility).toBe('visible')
|
||||
expect(tooltipGeometry!.left).toBeGreaterThanOrEqual(12)
|
||||
expect(tooltipGeometry!.right).toBeLessThanOrEqual(tooltipGeometry!.viewportWidth - 12)
|
||||
await page.mouse.move(0, 0)
|
||||
await pauseTooltip.waitFor({ state: 'hidden' })
|
||||
const snapshot = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd)
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/** Keep idle submissions in Chat across independently delivered Inbox and transcript frames. */
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Page } from 'playwright'
|
||||
import { expect, it } from 'vitest'
|
||||
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import {
|
||||
parseRemoteStreamClientMessage, parseRemoteStreamServerMessage,
|
||||
} from '@deepseek-ai/dsh-api-gateway/stream-protocol'
|
||||
import { launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts'
|
||||
import { newEnglishPage } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url))
|
||||
const SESSION_ID = 'idle-submission-handoff'
|
||||
const TEXT = 'IDLE_SUBMISSION_HANDOFF Keep this message in the conversation.'
|
||||
|
||||
interface Delivery {
|
||||
readonly stream: string
|
||||
readonly inbox?: InboxState
|
||||
readonly event?: SessionEvent
|
||||
readonly send: () => void
|
||||
}
|
||||
|
||||
/** Delay real Host frames while preserving FIFO within each logical stream. */
|
||||
class HistoryDeliveryGate {
|
||||
private readonly streams = new Map<string, string>()
|
||||
private buffered: Delivery[] = []
|
||||
holding = false
|
||||
|
||||
async install(page: Page): Promise<void> {
|
||||
await page.routeWebSocket('**/api/remote.mux', (socket) => {
|
||||
const server = socket.connectToServer()
|
||||
socket.onMessage((message) => {
|
||||
const frame = parseRemoteStreamClientMessage(String(message))
|
||||
if (frame.type === 'open') this.streams.set(frame.streamId, frame.endpoint)
|
||||
server.send(message)
|
||||
})
|
||||
server.onMessage((message) => {
|
||||
const frame = parseRemoteStreamServerMessage(String(message))
|
||||
const endpoint = this.streams.get(frame.streamId)
|
||||
if (!this.holding || (endpoint !== 'session/control' && endpoint !== 'session/follow')) {
|
||||
socket.send(message)
|
||||
return
|
||||
}
|
||||
const control = endpoint === 'session/control' && frame.type === 'item'
|
||||
? frame.value as { type: string; sessionId?: string; key?: string; value?: InboxState } : undefined
|
||||
const inbox = control?.type === 'projection' && control.sessionId === SESSION_ID && control.key === 'inbox'
|
||||
? control.value : undefined
|
||||
const follow = endpoint === 'session/follow' && frame.type === 'item'
|
||||
? frame.value as { type: string; event?: SessionEvent } : undefined
|
||||
this.buffered.push({ stream: frame.streamId, send: () => { socket.send(message) },
|
||||
...follow?.event === undefined ? {} : { event: follow.event }, ...inbox === undefined ? {} : { inbox } })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
inboxDelivery(queued: boolean, text = TEXT): Delivery | undefined {
|
||||
return this.buffered.find(item => item.inbox !== undefined && item.inbox['next-turn'].some(message =>
|
||||
message.content.some(block => block.type === 'text' && block.text === text)) === queued)
|
||||
}
|
||||
|
||||
admissionDelivery(text = TEXT): Delivery | undefined {
|
||||
return this.buffered.find(item => item.event?.type === 'user/message'
|
||||
&& item.event.data.content.some(block => block.type === 'text' && block.text === text))
|
||||
}
|
||||
|
||||
turnStartDelivery(): Delivery | undefined { return this.buffered.find(item => item.event?.type === 'turn/start') }
|
||||
|
||||
turnEndDelivery(): Delivery | undefined { return this.buffered.find(item => item.event?.type === 'turn/end') }
|
||||
|
||||
releaseThrough(target: Delivery): void {
|
||||
let reached = false
|
||||
this.buffered = this.buffered.filter((item) => {
|
||||
if (reached || item.stream !== target.stream) return true
|
||||
item.send()
|
||||
reached = item === target
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
releaseAll(): void {
|
||||
this.holding = false
|
||||
for (const item of this.buffered) item.send()
|
||||
this.buffered = []
|
||||
}
|
||||
}
|
||||
|
||||
async function placement(page: Page, input = TEXT) {
|
||||
return page.evaluate((text) => {
|
||||
const matching = (selector: string) => [...document.querySelectorAll<HTMLElement>(selector)]
|
||||
.filter(element => element.textContent?.includes(text))
|
||||
const echo = matching('[data-submission-echo]')
|
||||
const dock = matching('[data-queue-dock] li')
|
||||
const durable = matching('[data-chat-flow-kind="user"]')
|
||||
return {
|
||||
echo: echo.length,
|
||||
dock: dock.length,
|
||||
durable: durable.length,
|
||||
messageTop: (echo[0] ?? dock[0] ?? durable[0])?.getBoundingClientRect().top ?? null,
|
||||
composerTop: document.querySelector('[data-composer-card]')?.getBoundingClientRect().top ?? null,
|
||||
}
|
||||
}, input)
|
||||
}
|
||||
|
||||
it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first'] as const)(
|
||||
'keeps an idle submission in Chat under CPU throttling (%s)', async (order) => {
|
||||
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false })
|
||||
const releasePrompt = Promise.withResolvers<undefined>()
|
||||
const promptBlocked = Promise.withResolvers<undefined>()
|
||||
const gate = new HistoryDeliveryGate()
|
||||
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
|
||||
try {
|
||||
await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SESSION_ID)
|
||||
browser = await chromium.launch()
|
||||
const page = await newEnglishPage(browser)
|
||||
const tripwire = watchConsole(page)
|
||||
await gate.install(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.locator('[role="treeitem"]').first().click()
|
||||
await page.locator('[role="treeitem"]').nth(1).click()
|
||||
await page.locator('[data-chat-flow-kind="user"]').first().waitFor()
|
||||
await page.getByRole('button', { name: 'Send message', exact: true }).waitFor()
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 6 })
|
||||
await page.route('**/api/session/prompt', async (route) => {
|
||||
promptBlocked.resolve(undefined)
|
||||
await releasePrompt.promise
|
||||
await route.continue()
|
||||
}, { times: 1 })
|
||||
const trace: Array<{ phase: string } & Awaited<ReturnType<typeof placement>>> = []
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
gate.holding = true
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill(TEXT)
|
||||
await input.press('Enter')
|
||||
await promptBlocked.promise
|
||||
await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
|
||||
trace.push({ phase: 'optimistic', ...await placement(page) })
|
||||
releasePrompt.resolve(undefined)
|
||||
|
||||
if (order === 'transcript-first') {
|
||||
await expect.poll(() => gate.turnStartDelivery()).toBeDefined()
|
||||
gate.releaseThrough(gate.turnStartDelivery()!)
|
||||
await page.locator('[data-turn-process]').last().filter({ hasText: 'Deep diving' }).waitFor()
|
||||
await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
|
||||
trace.push({ phase: 'turn-start', ...await placement(page) })
|
||||
await expect.poll(() => gate.admissionDelivery()).toBeDefined()
|
||||
gate.releaseThrough(gate.admissionDelivery()!)
|
||||
await expect.poll(() => placement(page)).toMatchObject({ echo: 0, dock: 0, durable: 1 })
|
||||
trace.push({ phase: 'durable-before-inbox', ...await placement(page) })
|
||||
}
|
||||
|
||||
await expect.poll(() => gate.inboxDelivery(true)).toBeDefined()
|
||||
gate.releaseThrough(gate.inboxDelivery(true)!)
|
||||
await expect.poll(() => placement(page)).toMatchObject({ echo: order === 'inbox-first' ? 1 : 0, dock: 0, durable: order === 'transcript-first' ? 1 : 0 })
|
||||
trace.push({ phase: 'inbox', ...await placement(page) })
|
||||
|
||||
await expect.poll(() => gate.inboxDelivery(false)).toBeDefined()
|
||||
gate.releaseThrough(gate.inboxDelivery(false)!)
|
||||
await expect.poll(() => placement(page)).toMatchObject({ echo: order === 'inbox-first' ? 1 : 0, dock: 0, durable: order === 'transcript-first' ? 1 : 0 })
|
||||
trace.push({ phase: 'claim-projection', ...await placement(page) })
|
||||
|
||||
if (order === 'inbox-first') {
|
||||
await expect.poll(() => gate.turnStartDelivery()).toBeDefined()
|
||||
gate.releaseThrough(gate.turnStartDelivery()!)
|
||||
await page.locator('[data-turn-process]').last().filter({ hasText: 'Deep diving' }).waitFor()
|
||||
await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
|
||||
trace.push({ phase: 'turn-start', ...await placement(page) })
|
||||
}
|
||||
|
||||
gate.releaseAll()
|
||||
await expect.poll(() => placement(page)).toMatchObject({ echo: 0, dock: 0, durable: 1 })
|
||||
trace.push({ phase: 'durable', ...await placement(page) })
|
||||
for (const entry of trace) {
|
||||
expect(entry.messageTop, entry.phase).toBe(trace[0]?.messageTop)
|
||||
expect(entry.composerTop, entry.phase).toBe(trace[0]?.composerTop)
|
||||
}
|
||||
await settled
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 })
|
||||
await cdp.detach()
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} finally {
|
||||
releasePrompt.resolve(undefined)
|
||||
gate.releaseAll()
|
||||
try { await browser?.close() } finally { await scaffold.close() }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it.skipIf(webSnapshotMode() === 'record').each(['ABC', 'ACB', 'BAC', 'BCA', 'CAB', 'CBA'])(
|
||||
'hands off three rapid submissions through all Turns in Host order %s', async (hostOrder) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-input-handoff-'))
|
||||
const releases = new Map(['A', 'B', 'C'].map(id => [id, Promise.withResolvers<undefined>()]))
|
||||
const blocked = new Set<string>()
|
||||
const gate = new HistoryDeliveryGate()
|
||||
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
|
||||
let scaffold: Awaited<ReturnType<typeof launchWebScaffold>> | undefined
|
||||
try {
|
||||
const fixture = await readFile(FIXTURE, 'utf8')
|
||||
const recorded = deriveReplayScript(parseSessionLog(fixture))
|
||||
expect(recorded).toHaveLength(1)
|
||||
const override = join(root, 'replay.override.json')
|
||||
await writeFile(override, JSON.stringify([recorded[0], recorded[0], recorded[0]]))
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: override, compareReplaySession: false })
|
||||
await seedSession(scaffold, fixture, SESSION_ID)
|
||||
browser = await chromium.launch()
|
||||
const page = await newEnglishPage(browser)
|
||||
const tripwire = watchConsole(page)
|
||||
await gate.install(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.locator('[role="treeitem"]').first().click()
|
||||
await page.locator('[role="treeitem"]').nth(1).click()
|
||||
await page.locator('[data-chat-flow-kind="user"]').first().waitFor()
|
||||
await page.getByRole('button', { name: 'Send message', exact: true }).waitFor()
|
||||
const text = (id: string) => `RAPID_INPUT_${id}`
|
||||
await page.route('**/api/session/prompt', async (route) => {
|
||||
const request = route.request().postData() ?? ''
|
||||
const id = [...releases.keys()].find(id => request.includes(text(id)))
|
||||
if (id === undefined) throw new Error('unexpected prompt in rapid input fixture')
|
||||
blocked.add(id)
|
||||
await releases.get(id)!.promise
|
||||
await route.continue()
|
||||
})
|
||||
gate.holding = true
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
for (const id of releases.keys()) {
|
||||
await input.fill(text(id))
|
||||
await input.press('Enter')
|
||||
await expect.poll(() => blocked.has(id)).toBe(true)
|
||||
await expect.poll(() => placement(page, text(id))).toMatchObject({ echo: 1, dock: 0, durable: 0 })
|
||||
}
|
||||
const entered: string[] = []
|
||||
for (const id of hostOrder) {
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
releases.get(id)!.resolve(undefined)
|
||||
await expect.poll(() => gate.turnStartDelivery()).toBeDefined()
|
||||
gate.releaseThrough(gate.turnStartDelivery()!)
|
||||
await expect.poll(() => gate.admissionDelivery(text(id))).toBeDefined()
|
||||
gate.releaseThrough(gate.admissionDelivery(text(id))!)
|
||||
entered.push(id)
|
||||
for (const candidate of releases.keys()) {
|
||||
const admitted = entered.includes(candidate)
|
||||
await expect.poll(() => placement(page, text(candidate))).toMatchObject({
|
||||
echo: admitted ? 0 : 1, dock: 0, durable: admitted ? 1 : 0,
|
||||
})
|
||||
}
|
||||
await expect.poll(() => gate.inboxDelivery(true, text(id))).toBeDefined()
|
||||
gate.releaseThrough(gate.inboxDelivery(true, text(id))!)
|
||||
await expect.poll(() => placement(page, text(id))).toMatchObject({ echo: 0, dock: 0, durable: 1 })
|
||||
await expect.poll(() => gate.inboxDelivery(false, text(id))).toBeDefined()
|
||||
gate.releaseThrough(gate.inboxDelivery(false, text(id))!)
|
||||
await expect.poll(() => gate.turnEndDelivery()).toBeDefined()
|
||||
gate.releaseThrough(gate.turnEndDelivery()!)
|
||||
await settled
|
||||
}
|
||||
gate.releaseAll()
|
||||
const actual = await page.locator('[data-chat-flow-kind="user"]').allTextContents()
|
||||
expect(actual.filter(value => value.includes('RAPID_INPUT_')).map(value =>
|
||||
[...releases.keys()].find(id => value.includes(text(id))))).toEqual(hostOrder.split(''))
|
||||
expect(await page.locator('[data-submission-echo]').count()).toBe(0)
|
||||
expect(await page.locator('[data-queue-dock]').count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} finally {
|
||||
for (const release of releases.values()) release.resolve(undefined)
|
||||
gate.releaseAll()
|
||||
try { await browser?.close() } finally {
|
||||
try { await scaffold?.close() } finally { await rm(root, { recursive: true, force: true }) }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
acknowledgeReloadConnectionLoss, launchWebScaffold,
|
||||
seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts'
|
||||
|
||||
// Borrowed read-only: this scenario needs any settled assistant message to
|
||||
// address, not a new recording (message-actions / sidebar-scrollbar pattern).
|
||||
@@ -67,7 +67,7 @@ describe('web e2e: durable per-message feedback', () => {
|
||||
await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
const like = page.getByRole('button', { name: 'Good response' }).first()
|
||||
await like.waitFor({ timeout: 30_000 })
|
||||
await like.scrollIntoViewIfNeeded()
|
||||
await scrollIntoView(like)
|
||||
await like.hover()
|
||||
await like.click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Submit feedback' })
|
||||
@@ -111,12 +111,12 @@ describe('web e2e: durable per-message feedback', () => {
|
||||
// the unrated control is what triggers the authoritative re-read.
|
||||
const cold = page.getByRole('button', { name: 'Good response' }).first()
|
||||
await cold.waitFor({ timeout: 30_000 })
|
||||
await cold.scrollIntoViewIfNeeded()
|
||||
await scrollIntoView(cold)
|
||||
await cold.hover()
|
||||
|
||||
const restored = page.getByRole('button', { name: 'Remove rating' }).first()
|
||||
await restored.waitFor({ timeout: 30_000 })
|
||||
await restored.scrollIntoViewIfNeeded()
|
||||
await scrollIntoView(restored)
|
||||
await restored.hover()
|
||||
await expect.poll(() => restored.getAttribute('aria-pressed'), { timeout: 15_000 }).toBe('true')
|
||||
// The retract label sits on the Dislike side: the Like stays unpressed.
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture,
|
||||
watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, expandTurnProcesses, newEnglishPage } from './support.ts'
|
||||
import { connectFreshWorkspace, expandTurnProcesses, newEnglishPage, scrollIntoView } from './support.ts'
|
||||
|
||||
const DIR = fileURLToPath(new URL('../../../snapshots/web/present', import.meta.url))
|
||||
const FIXTURE = join(DIR, 'session.v3.jsonl')
|
||||
@@ -272,7 +272,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
|
||||
expect(geometry.openFontSize).toBe('11px')
|
||||
await page.setViewportSize({ width: 480, height: 900 })
|
||||
const row = page.locator('[data-presented-files-row]')
|
||||
await row.scrollIntoViewIfNeeded()
|
||||
await scrollIntoView(row)
|
||||
for (const card of await row.getByRole('button').all()) {
|
||||
const bounds = await card.boundingBox()
|
||||
expect(bounds).not.toBeNull()
|
||||
|
||||
@@ -227,6 +227,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let seededThroughSeq = -1
|
||||
let openingWindow: unknown
|
||||
|
||||
beforeAll(async () => {
|
||||
// The POSIX terminal fixture stays off Windows; the pinned desktop applies
|
||||
@@ -266,6 +267,27 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
if (MODE !== 'record') {
|
||||
// A one-message tail makes this short recording exercise the real Load earlier path.
|
||||
let pagedOpening = false
|
||||
await page.routeWebSocket('**/api/remote.mux', (socket) => {
|
||||
const server = socket.connectToServer()
|
||||
socket.onMessage((message) => {
|
||||
const frame = JSON.parse(String(message)) as {
|
||||
type: string
|
||||
endpoint?: string
|
||||
payload: { args: { request: { maxMessages: number; turnWindow?: { minMessages: number; minTurns: number } } } }
|
||||
}
|
||||
if (!pagedOpening && frame.type === 'open' && frame.endpoint === 'session/follow') {
|
||||
pagedOpening = true
|
||||
openingWindow = { ...frame.payload.args.request }
|
||||
frame.payload.args.request.maxMessages = 1
|
||||
delete frame.payload.args.request.turnWindow
|
||||
server.send(JSON.stringify(frame))
|
||||
} else server.send(message)
|
||||
})
|
||||
})
|
||||
}
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
@@ -330,6 +352,16 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
await sessionRow.click()
|
||||
// Settled barrier for history: the recorded final assistant text renders.
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
expect(openingWindow).toMatchObject({ maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } })
|
||||
expect(await page.getByText(PROMPT, { exact: true }).count()).toBe(0)
|
||||
const [paging] = await Promise.all([
|
||||
page.waitForRequest('**/api/session/page'),
|
||||
page.getByRole('button', { name: 'Load earlier', exact: true }).click(),
|
||||
])
|
||||
expect(paging.postDataJSON()).toMatchObject({
|
||||
payload: { args: { request: { maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } } } },
|
||||
})
|
||||
await expect.poll(() => page.getByText(PROMPT, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('compact', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText(/^Compacted \d+ history items \(~\d+ tokens\)$/).count(), {
|
||||
timeout: 10_000,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { expandAssistantStream } from '@deepseek-ai/dsh-llm'
|
||||
import { parseRemoteStreamServerMessage } from '@deepseek-ai/dsh-api-gateway/stream-protocol'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
|
||||
compareOrRefreshGolden, fixtureUserPrompts,
|
||||
@@ -191,13 +192,21 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('web e2e: composer shortcut steers directly', () => {
|
||||
describe.each(['reconnect', 'delayed-inbox'] as const)('web e2e: composer shortcut steers directly (%s)', (handoff) => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let remoteSocket: WebSocketRoute | undefined
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
let holdInbox = false
|
||||
let heldStream: string | undefined
|
||||
let heldControl: (() => void)[] = []
|
||||
const releaseInbox = (): void => {
|
||||
holdInbox = false
|
||||
for (const send of heldControl) send()
|
||||
heldControl = []
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS, compareReplaySession: false })
|
||||
@@ -206,7 +215,17 @@ describe('web e2e: composer shortcut steers directly', () => {
|
||||
page = await newEnglishPage(browser)
|
||||
await page.routeWebSocket('**/api/remote.mux', (route) => {
|
||||
remoteSocket = route
|
||||
route.connectToServer()
|
||||
const server = route.connectToServer()
|
||||
server.onMessage((message) => {
|
||||
const frame = parseRemoteStreamServerMessage(String(message))
|
||||
if (holdInbox && frame.type === 'item') {
|
||||
const value = frame.value as { type?: string; key?: string }
|
||||
if (value.type === 'projection' && value.key === 'inbox') heldStream = frame.streamId
|
||||
}
|
||||
// Hold the remaining control stream in FIFO order, while history keeps flowing.
|
||||
if (holdInbox && frame.streamId === heldStream) heldControl.push(() => { route.send(message) })
|
||||
else route.send(message)
|
||||
})
|
||||
})
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
@@ -251,6 +270,7 @@ describe('web e2e: composer shortcut steers directly', () => {
|
||||
return next()
|
||||
})
|
||||
try {
|
||||
holdInbox = handoff === 'delayed-inbox'
|
||||
await composer.getByRole('radio', { name: 'Yes' }).click()
|
||||
await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
|
||||
await expect.poll(() => claimed, { timeout: 10_000 }).toBe(true)
|
||||
@@ -259,16 +279,25 @@ describe('web e2e: composer shortcut steers directly', () => {
|
||||
}))
|
||||
expect(await page.getByText(STEER, { exact: true }).count()).toBe(1)
|
||||
expect(await page.locator('[data-submission-echo]').filter({ hasText: STEER }).count()).toBe(1)
|
||||
const socket = remoteSocket
|
||||
if (socket === undefined) throw new Error('steering page has no Remote connection')
|
||||
const warningStart = tripwire.warnings.length
|
||||
await socket.close({ code: 1012, reason: 'steering reconnect checkpoint' })
|
||||
await expect.poll(() => remoteSocket !== socket, { timeout: 10_000 }).toBe(true)
|
||||
await expect.poll(() => page.locator('[data-submission-echo]').count(), { timeout: 10_000 }).toBe(0)
|
||||
expect(await page.getByText(STEER, { exact: true }).count()).toBe(0)
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
if (handoff === 'reconnect') {
|
||||
const socket = remoteSocket
|
||||
if (socket === undefined) throw new Error('steering page has no Remote connection')
|
||||
const warningStart = tripwire.warnings.length
|
||||
await socket.close({ code: 1012, reason: 'steering reconnect checkpoint' })
|
||||
await expect.poll(() => remoteSocket !== socket, { timeout: 10_000 }).toBe(true)
|
||||
await expect.poll(() => page.locator('[data-submission-echo]').count(), { timeout: 10_000 }).toBe(0)
|
||||
expect(await page.getByText(STEER, { exact: true }).count()).toBe(0)
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
} else {
|
||||
await expect.poll(() => heldControl.length, { timeout: 10_000 }).toBeGreaterThan(0)
|
||||
admission.resolve(undefined)
|
||||
await page.locator('[data-chat-flow-kind="steering"]').filter({ hasText: STEER }).waitFor({ timeout: 10_000 })
|
||||
expect(await page.getByText(STEER, { exact: true }).count()).toBe(1)
|
||||
expect(await pendingSteering.count()).toBe(0)
|
||||
}
|
||||
} finally {
|
||||
admission.resolve(undefined)
|
||||
releaseInbox()
|
||||
stopHolding()
|
||||
await settled
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { expect } from 'vitest'
|
||||
|
||||
/** The built page under test; `pnpm run test:web` rebuilds it before running. */
|
||||
export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url))
|
||||
@@ -55,6 +56,18 @@ export async function newEnglishPage(browser: Browser, height = 1000): Promise<P
|
||||
return await browser.newPage({ viewport: { width: 1680, height }, locale: 'en-US', timezoneId: 'Asia/Shanghai' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll a locator whose rendered element can be replaced during layout.
|
||||
* @param target - locator resolved again when its previous element detached.
|
||||
*/
|
||||
export async function scrollIntoView(target: Locator): Promise<void> {
|
||||
await expect.poll(() => target.evaluate((element) => {
|
||||
if (!element.isConnected) return false
|
||||
element.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' })
|
||||
return true
|
||||
}), { timeout: 10_000 }).toBe(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand every eligible Turn process and secondary group so a Tool-focused
|
||||
* scenario can exercise the original row contract beneath product-default
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { expandTurnProcesses, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts'
|
||||
|
||||
const EXPECTED_DIR = fileURLToPath(new URL('./expected/thinking-markdown', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/thinking-markdown/ui.expected.md', import.meta.url))
|
||||
@@ -159,7 +159,7 @@ describe('web e2e: secondary Thinking Markdown', () => {
|
||||
expect(await markdown.locator('hr').count()).toBe(1)
|
||||
expect(await markdown.locator('table').count()).toBe(1)
|
||||
expect(await markdown.locator('.katex').count()).toBe(7)
|
||||
await markdown.locator('pre').scrollIntoViewIfNeeded()
|
||||
await scrollIntoView(markdown.locator('pre'))
|
||||
await expect.poll(() => markdown.locator('pre.shiki').count(), { timeout: 15_000 }).toBe(1)
|
||||
const snapshot = (await captureStableAria(page, '[data-variant="think"][data-expanded]', scaffold.workspaceCwd))
|
||||
.split(LONG_TOKEN).join('{{longToken}}')
|
||||
@@ -233,7 +233,7 @@ describe('web e2e: secondary Thinking Markdown', () => {
|
||||
expect(answerSize).toBeGreaterThan(Number.parseFloat(summaryStyle.fontSize))
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
const groupScroll = thinking.locator('xpath=ancestor::*[@data-step-process-body][1]')
|
||||
await groupScroll.scrollIntoViewIfNeeded()
|
||||
await scrollIntoView(groupScroll)
|
||||
await groupScroll.evaluate((host) => {
|
||||
const row = host.querySelector('[data-variant="think"] tbody tr:nth-child(12)')
|
||||
if (row === null) throw new Error('tall Thinking table row missing')
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { expandOwningTurnProcess, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/tool-details', import.meta.url))
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/tool-details/session.v3.jsonl', import.meta.url))
|
||||
@@ -73,7 +73,7 @@ describe.skipIf(MODE === 'record')('web e2e: compact Tool details', () => {
|
||||
const card = page.locator('[data-tool="schedule_create"]')
|
||||
expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
const reminders = page.locator('[data-tool="schedule_list"]')
|
||||
await reminders.scrollIntoViewIfNeeded()
|
||||
await scrollIntoView(reminders)
|
||||
const list = reminders.getByRole('list').first()
|
||||
await list.evaluate((element) => {
|
||||
const second = element.children[1]!
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"tests/markdown-inline-code-links.e2e.ts",
|
||||
"tests/clickable-links-gallery.e2e.ts",
|
||||
"tests/queue-actions.e2e.ts",
|
||||
"tests/idle-submission-handoff.e2e.ts",
|
||||
"tests/cold-blank-session.e2e.ts",
|
||||
"tests/blank-writer-reuse.e2e.ts",
|
||||
"tests/queue-image.e2e.ts",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
|
||||
event-producer-consumer.md: 13ac82b2c9fb0b2aeeaa2bec9c736db5b5b63992
|
||||
event-producer-consumer.zh.md: fce8463041181139e45fb5f3a9ab14c87117135f
|
||||
event-producer-consumer.md: 1d844013f1230d6dbe535c3942279683f03738ce
|
||||
event-producer-consumer.zh.md: 73438c3f19ef6850674a27bf8f490fe4cf52b420
|
||||
@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
|
||||
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:596`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:616`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:595`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:602`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:45`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
|
||||
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:596`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:616`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:595`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:602`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:43`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
|
||||
README.md: e12ad7623eefc73c94e99cc3078e0ae78d6a969a
|
||||
README.zh.md: 5f4ceec9a8e8303cad6651dbbe7af27fc07bccec
|
||||
README.md: fa2a251e94cfa2855790d7d71457ac4bdf2ff752
|
||||
README.zh.md: d1a73ed5843fddadfc2f401fee1a86eece4e5272
|
||||
@@ -43,12 +43,12 @@ Projection reads retain loading and failure state independently of their values.
|
||||
|
||||
Accepted prompts and observed running keep their Client display conversion across list refresh, reconnect, and Session-object replacement. The Manager retains these observations by Session id; they do not establish durable history. Drafts, projection stores, and sidebar display rules retain their existing behavior. The [blank rollback decision](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md) defines retention and late-response handling.
|
||||
|
||||
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
|
||||
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one Turn-aligned page, and `loadThrough(seq)` — the turn-jump loader — loops Turn-aligned pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
|
||||
|
||||
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
|
||||
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement until the display handoff. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. Queued echoes retire one animation frame after queue acceptance; Chat echoes follow the admission rule below. Identified failures and abandonment retire unadmitted echoes immediately. Disposal preserves observed admission and fails other unsettled submissions. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
|
||||
|
||||
|
||||
During uninterrupted following, steering echoes stay mounted through Inbox acceptance and claim until their durable messages arrive. A replacement follow baseline withdraws earlier receipt-confirmed steering echoes as observed, using their accepted attachment references; unconfirmed submissions remain pending. Host data then supplies the pending or admitted rows. Reconnecting between claim and admission can briefly omit that bubble; withdrawal confirms acceptance, not execution or failure.
|
||||
During uninterrupted following, transcript and steering echoes stay in Chat through Inbox acceptance and claim until their durable messages arrive. After a local Chat message is admitted, its identity remains until the Inbox watermark reaches its next-turn or next-step claim seq: Chat already renders the durable Node, while Chat and QueueDock exclude matching stale Inbox rows. Other queued messages remain visible. A replacement follow baseline withdraws receipt-confirmed echoes as observed, using their accepted attachment references; unconfirmed submissions remain pending. Host data then supplies the pending or admitted rows. Reconnecting between claim and admission can briefly omit that bubble; withdrawal confirms acceptance, not execution or failure.
|
||||
|
||||
The user-invocable `skills/list` metadata includes the winning provider’s optional instruction-file `path`. The composer can preview that file without loading every skill body or activating a cold Agent.
|
||||
|
||||
@@ -56,7 +56,9 @@ Fork copies the exact inclusive event prefix selected by `atSeq`, including a cu
|
||||
|
||||
A resume blocked by an existing write handle returns `session/writer-held` with the Session id; other resume failures retain `gateway/internal`.
|
||||
|
||||
`loadThrough(seq)` retains older pages privately until its shared target is covered or loading ends, then publishes the successful pages as one ordered prepend. Live events remain visible while history loads. A later page failure retains the successful prefix; replacing the history window discards buffered pages from the replaced window. Ordinary `loadOlder()` still publishes its single page directly.
|
||||
`loadThrough(seq)` retains older pages privately until its shared target is covered or loading ends, then publishes the successful pages as one ordered prepend. Live events remain visible while history loads. A later page failure retains the successful prefix; replacing the history window discards buffered pages from the replaced window. Ordinary `loadOlder()` publishes one Host-selected page directly.
|
||||
|
||||
The Client's initial `follow`, reconnect openings, and `loadOlder()` request at least 50 append-origin `user/message` and `assistant/message` events and cross at least two `turn/start` events, including the partial Turn at the loaded window's beginning. The Host stops at the first Turn start satisfying both minima, or at 500 counted messages or history exhaustion, whichever comes first. Steering does not add a Turn boundary. Intervening events accompany the page without counting toward the message budget. The optional `turnWindow` on page and follow requests supplies these minima under `maxMessages`. `loadThrough()` uses the same rules with a 200-message minimum per page; the 500-message cap does not limit the whole jump. Requests without `turnWindow` retain message-aligned paging.
|
||||
|
||||
Queue edits replace pending content with non-empty text only.
|
||||
|
||||
|
||||
@@ -43,12 +43,12 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时
|
||||
|
||||
已受理的提示词与已观察到的运行,会使客户端展示转换跨列表刷新、重连及 Session 对象替换保留。Manager 按 Session id 保留这些观察;它们不证明持久历史已经产生。草稿、投影存储和侧边栏展示规则保留原有行为。[blank 回退修复决策](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 定义保留期限及迟到响应处理。
|
||||
|
||||
Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
|
||||
Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页按轮次开头对齐的历史,而 `loadThrough(seq)`——轮次跳转加载器——按轮次开头对齐页面循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
|
||||
|
||||
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的提示词失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。
|
||||
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并保留该位置直到展示接管。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。排队回显在队列接受后延迟一个动画帧退休;Chat 回显遵循下述入档规则。尚未入档的回显在带标识的提示词失败或被放弃时立即退休。销毁时保留已观察到的入档结果,其余未结算提交按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。
|
||||
|
||||
|
||||
正常在线时,steering 回显在 Inbox 接受与领取期间保持挂载,直到持久消息到达。新的 follow 基线会将此前已有接收回执的 steering 回显按 observed 撤去,使用已接受的附件引用完成结算;尚未确认接收的提交继续保留。待处理或已入档的消息随后由 Host 数据呈现。在领取与入档之间重连时,气泡可能短暂消失;撤去回显只确认接收,不代表执行或失败。
|
||||
正常在线时,transcript 与 steering 回显在 Inbox 接受与领取期间留在 Chat,直到持久消息到达。本地 Chat 消息入档后,其身份保留到 Inbox 水位覆盖 next-turn 或 next-step 领取序号:Chat 已显示真实 Node,而 Chat 与 QueueDock 排除匹配的旧 Inbox 行。其他排队消息照常显示。新的 follow 基线会将已有接收回执的回显按 observed 撤去,使用已接受的附件引用完成结算;尚未确认接收的提交继续保留。待处理或已入档的消息随后由 Host 数据呈现。在领取与入档之间重连时,气泡可能短暂消失;撤去回显只确认接收,不代表执行或失败。
|
||||
|
||||
面向用户调用的 `skills/list` 元数据包含胜出提供方可选的指令文件 `path`。输入框可据此预览文件,无需加载每个 skill 的正文或激活冷态 Agent。
|
||||
|
||||
@@ -56,7 +56,9 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在
|
||||
|
||||
恢复会话时若已有写句柄占用,返回 `session/writer-held`,并携带会话 id;其他恢复失败仍返回 `gateway/internal`。
|
||||
|
||||
`loadThrough(seq)` 在共享目标被覆盖或加载结束前私下保留较早页面,随后把成功取得的页面按顺序作为一次前插发布。历史加载期间实时事件仍然可见。后续页面失败时保留已成功取得的部分;历史窗口被替换时丢弃被替换窗口的暂存页面。普通 `loadOlder()` 仍直接发布其单页结果。
|
||||
`loadThrough(seq)` 在共享目标被覆盖或加载结束前私下保留较早页面,随后把成功取得的页面按顺序作为一次前插发布。历史加载期间实时事件仍然可见。后续页面失败时保留已成功取得的部分;历史窗口被替换时丢弃被替换窗口的暂存页面。普通 `loadOlder()` 直接发布 Host 选取的一页结果。
|
||||
|
||||
Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以 append 方式追加的 `user/message` 和 `assistant/message` 事件,并向前跨过至少两个 `turn/start`,其中包含已加载窗口开头尚未补齐的轮次。Host 在首次同时满足两个下限的轮次开头停止;若先达到 500 条计数消息或历史已耗尽,则立即返回。Steering 不增加轮次分界。区间内的其他事件随页返回,但不计入消息预算。分页和 follow 请求可选的 `turnWindow` 在 `maxMessages` 上限内指定这两个下限。`loadThrough()` 复用相同规则,仅将每页消息数下限设为 200;500 条上限不限制整次跳转。不带 `turnWindow` 的请求保留按消息对齐的分页方式。
|
||||
|
||||
队列编辑仅允许用非空文本替换待处理内容。
|
||||
|
||||
|
||||
@@ -68,9 +68,10 @@ export interface ISession {
|
||||
/**
|
||||
* Register one local submission echo in `snapshot.pendingSubmissions`,
|
||||
* synchronously, before the caller serializes and sends the prompt. The
|
||||
* echo retires when a durable `user/message` event or queue occurrence
|
||||
* carrying the returned identity arrives, or when the identified prompt
|
||||
* call fails.
|
||||
* Chat echoes persist until durable admission; transcript identities also
|
||||
* wait for the Inbox claim watermark so stale queue rows stay suppressed.
|
||||
* Queued echoes retire on queue acceptance. Identified failures retire
|
||||
* submissions that have not already reached durable admission.
|
||||
* @param input - echo content and the optional settlement callback.
|
||||
* @returns the minted identity for {@link prompt} plus the pre-prompt abandon path.
|
||||
*/
|
||||
@@ -118,7 +119,9 @@ export interface ISession {
|
||||
*/
|
||||
rename(title: string): Promise<RemoteResult<{ title: string; seq: SessionSeq }>>
|
||||
/**
|
||||
* Extend the history window backwards (older messages pagination).
|
||||
* Extend history by at least 50 messages and two Turn starts, including a
|
||||
* partial Turn at the window's beginning. Stop at 500 messages or history
|
||||
* exhaustion even when those minima cannot be met. Publish one prepend.
|
||||
* @returns completion; failures land in snapshot.openState/loadingOlder.
|
||||
*/
|
||||
loadOlder(): Promise<void>
|
||||
@@ -127,6 +130,8 @@ export interface ISession {
|
||||
* turn-jump loader. Repeated calls while a jump is paging lower its shared
|
||||
* target and return the in-flight completion; `snapshot.loadingOlder` is
|
||||
* the busy signal for the whole jump.
|
||||
* Each page uses the same Turn alignment as loadOlder, with a 200-message
|
||||
* minimum. The 500-message limit applies per page, not to the whole jump.
|
||||
* Older pages publish together at completion, including successful pages
|
||||
* before a later failure; live events remain independently visible.
|
||||
* @param seq - durable event seq the window must reach (a turn's `turn/start` seq).
|
||||
|
||||
@@ -46,7 +46,7 @@ export type PendingSubmissionPlacement = 'transcript' | 'queued' | 'steering'
|
||||
export interface PendingSubmission {
|
||||
/** The prompt RPC identity; the durable `user/message` source echoes it as `rpcId`. */
|
||||
readonly requestId: SessionRequestId
|
||||
/** Expected surface until the Host reports the admitted queue or durable occurrence. */
|
||||
/** Surface fixed at submission time; Inbox acceptance does not move a Chat echo into the dock. */
|
||||
readonly placement: PendingSubmissionPlacement
|
||||
/** Client wall-clock ms when the submission began. */
|
||||
readonly time: number
|
||||
|
||||
@@ -117,6 +117,16 @@ export class ProjectionValueStore {
|
||||
return this.rows.get(key)?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the accepted Host watermark without subscribing or copying a value.
|
||||
* @param key - projection key.
|
||||
* @returns the current sequence, or undefined for absent and cached values.
|
||||
*/
|
||||
seqOf(key: string): SessionSeqCursor | undefined {
|
||||
const row = this.rows.get(key)
|
||||
return row?.kind === 'sequenced' ? row.seq : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every current projection value as one reference-stable snapshot.
|
||||
* @returns The same frozen value map until a row changes.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Sessions remain resident after creation so their open Remote sources keep running off-screen.
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
|
||||
import type { InboxState, InboxTarget } from '@deepseek-ai/dsh-agent/types'
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
|
||||
@@ -48,12 +48,19 @@ function projectionsBaseline(value: SessionProjectionBaseline): ProjectionsBasel
|
||||
}
|
||||
}
|
||||
|
||||
/** Messages requested per history page. */
|
||||
/** Minimum message count for ordinary history windows. */
|
||||
export const PAGE_MESSAGES = 50
|
||||
|
||||
/** Messages requested per page while a turn jump loops backwards (fewer, larger round trips). */
|
||||
const HISTORY_PAGE_OPTIONS = { maxMessages: 500, turnWindow: { minMessages: PAGE_MESSAGES, minTurns: 2 } }
|
||||
|
||||
/** Minimum messages per page while a turn jump loops backwards. */
|
||||
export const JUMP_PAGE_MESSAGES = 200
|
||||
|
||||
const JUMP_PAGE_OPTIONS = {
|
||||
...HISTORY_PAGE_OPTIONS,
|
||||
turnWindow: { ...HISTORY_PAGE_OPTIONS.turnWindow, minMessages: JUMP_PAGE_MESSAGES },
|
||||
}
|
||||
|
||||
interface PendingHistory {
|
||||
beforeSeq: SessionLogOffset
|
||||
hasMore: boolean
|
||||
@@ -126,12 +133,15 @@ export class Session implements SessionFace {
|
||||
* Inbox projection and its durable event cannot both retire one echo. */
|
||||
private readonly submissionSettlements = new Map<SessionRequestId, {
|
||||
readonly placement: PendingSubmission['placement']
|
||||
/** Latest received next-step position; null means claimed but not yet admitted. */
|
||||
/** Latest received Inbox position; null means removed from that queue. */
|
||||
receipt?: {
|
||||
readonly target: InboxTarget
|
||||
readonly seq: number
|
||||
readonly index: number | null
|
||||
readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[]
|
||||
}
|
||||
/** Durable admission may precede the Inbox projection acknowledging its claim. */
|
||||
admitted?: readonly (ImageAttachmentRef | FileAttachmentRef)[]
|
||||
readonly onRetire?: ((retirement: PendingSubmissionRetirement) => void) | undefined
|
||||
retiring: boolean
|
||||
}>()
|
||||
@@ -392,7 +402,7 @@ export class Session implements SessionFace {
|
||||
return promise
|
||||
}
|
||||
|
||||
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend. */
|
||||
/** Prepend one Turn-aligned page: at least 50 messages and two Turn starts, capped at 500 messages. */
|
||||
async loadOlder(): Promise<void> {
|
||||
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
|
||||
const events = this.events
|
||||
@@ -400,7 +410,10 @@ export class Session implements SessionFace {
|
||||
this.loadingOlder = true
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
await events.prepend({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
|
||||
await events.prepend({
|
||||
beforeSeq: this.baseSeq,
|
||||
...HISTORY_PAGE_OPTIONS,
|
||||
})
|
||||
} catch (error) {
|
||||
if (!isRemoteFailure(error)) {
|
||||
console.error('[session-controller] loadOlder failed:', error)
|
||||
@@ -444,7 +457,7 @@ export class Session implements SessionFace {
|
||||
while (pending.hasMore && this.jumpTargetSeq !== null && pending.beforeSeq > this.jumpTargetSeq) {
|
||||
if (generation !== this.openGeneration) return
|
||||
const before = pending.beforeSeq
|
||||
await events.prepend({ beforeSeq: before, maxMessages: JUMP_PAGE_MESSAGES })
|
||||
await events.prepend({ beforeSeq: before, ...JUMP_PAGE_OPTIONS })
|
||||
// No-progress guard: an empty or dropped page that still claims more
|
||||
// history must end the loop, not spin it.
|
||||
if (pending.beforeSeq >= before) return
|
||||
@@ -586,10 +599,10 @@ export class Session implements SessionFace {
|
||||
async dispose(): Promise<void> {
|
||||
this.stopObservingInbox()
|
||||
// Unsettled echoes retire as failed so their owners can restore or
|
||||
// release browser resources; echoes already scheduled as observed keep
|
||||
// that settlement.
|
||||
for (const requestId of [...this.submissionSettlements.keys()]) {
|
||||
this.retireFailedSubmission(requestId)
|
||||
// release browser resources; admitted echoes keep their observed outcome.
|
||||
for (const [requestId, settlement] of [...this.submissionSettlements]) {
|
||||
if (settlement.admitted !== undefined) this.scheduleObservedRetirement(requestId, settlement.admitted)
|
||||
else this.retireFailedSubmission(requestId)
|
||||
}
|
||||
this.openGeneration++
|
||||
const events = this.events
|
||||
@@ -615,7 +628,7 @@ export class Session implements SessionFace {
|
||||
})
|
||||
this.events = events
|
||||
try {
|
||||
await events.open({ maxMessages: PAGE_MESSAGES })
|
||||
await events.open(HISTORY_PAGE_OPTIONS)
|
||||
if (generation !== this.openGeneration || this.events !== events) return
|
||||
this.openState = 'open'
|
||||
} catch (error) {
|
||||
@@ -672,7 +685,7 @@ export class Session implements SessionFace {
|
||||
if (visible.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
|
||||
if (projections !== undefined) this.projections.seed(projections)
|
||||
this.eventSource.replace(visible, hasMore)
|
||||
// A new follow baseline replaces optimistic steering with Host-owned rows.
|
||||
// A new follow baseline replaces confirmed optimistic inputs with Host-owned rows.
|
||||
// Receipt-backed inputs are accepted, not failed, even if their history is outside this window.
|
||||
if (projections !== undefined) {
|
||||
for (const [requestId, { receipt }] of this.submissionSettlements) {
|
||||
@@ -684,7 +697,9 @@ export class Session implements SessionFace {
|
||||
for (const entry of visible) this.observeSubmissionEvent(entry.event)
|
||||
if (projections !== undefined) {
|
||||
const inbox = projections.values.inbox as InboxState | undefined
|
||||
this.observeSteeringInsertions(inbox?.['next-step'] ?? [], 0, projections.asOfSeq)
|
||||
for (const target of ['next-turn', 'next-step'] as const) {
|
||||
this.observeSubmissionInsertions(target, inbox?.[target] ?? [], 0, projections.asOfSeq)
|
||||
}
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
@@ -748,55 +763,76 @@ export class Session implements SessionFace {
|
||||
if (this.submissionSettlements.size === 0) return
|
||||
if (event.type === 'agent/inbox/spliced') {
|
||||
const { target, start, removedCount = 0, inserted, outcome } = event.data
|
||||
if (target === 'next-step') {
|
||||
for (const [requestId, settlement] of this.submissionSettlements) {
|
||||
const receipt = settlement.receipt
|
||||
if (receipt === undefined || receipt.index === null || receipt.seq >= event.seq) continue
|
||||
const removed = receipt.index >= start && receipt.index < start + removedCount
|
||||
if (removed && outcome === 'canceled') this.retireFailedSubmission(requestId)
|
||||
else settlement.receipt = {
|
||||
...receipt,
|
||||
seq: event.seq,
|
||||
index: removed ? null : receipt.index < start ? receipt.index : receipt.index + inserted.length - removedCount,
|
||||
}
|
||||
for (const [requestId, settlement] of this.submissionSettlements) {
|
||||
const receipt = settlement.receipt
|
||||
if (receipt?.target !== target || receipt.index === null || receipt.seq >= event.seq) continue
|
||||
const removed = receipt.index >= start && receipt.index < start + removedCount
|
||||
if (removed && outcome === 'canceled') this.retireFailedSubmission(requestId)
|
||||
else settlement.receipt = {
|
||||
...receipt,
|
||||
seq: event.seq,
|
||||
index: removed ? null : receipt.index < start ? receipt.index : receipt.index + inserted.length - removedCount,
|
||||
}
|
||||
this.observeSteeringInsertions(inserted, start, event.seq)
|
||||
}
|
||||
this.observeSubmissionInsertions(target, inserted, start, event.seq)
|
||||
for (const message of inserted) this.observeSubmissionMessage(message, false)
|
||||
return
|
||||
}
|
||||
if (event.type === 'request/context' || event.type === 'turn/end') {
|
||||
for (const [requestId, settlement] of this.submissionSettlements) {
|
||||
if (settlement.receipt?.index === null && settlement.receipt.seq < event.seq) this.retireFailedSubmission(requestId)
|
||||
if (settlement.admitted === undefined && settlement.receipt?.index === null
|
||||
&& settlement.receipt.seq < event.seq) this.retireFailedSubmission(requestId)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === 'user/message') this.observeSubmissionMessage(event.data, true)
|
||||
}
|
||||
|
||||
private observeSteeringInsertions(messages: readonly UserMessage[], start: number, seq: number): void {
|
||||
private observeSubmissionInsertions(target: InboxTarget, messages: readonly UserMessage[], start: number, seq: number): void {
|
||||
for (const [index, message] of messages.entries()) {
|
||||
const source = message.source
|
||||
if (source.kind !== 'user' || !('rpcId' in source)) continue
|
||||
const settlement = this.submissionSettlements.get(source.rpcId)
|
||||
if (settlement?.placement !== 'steering' || settlement.retiring || (settlement.receipt?.seq ?? -1) > seq) continue
|
||||
settlement.receipt = { seq, index: start + index, attachments: attachmentRefsIn(message.content) }
|
||||
if (settlement === undefined || settlement.placement === 'queued'
|
||||
|| settlement.retiring || (settlement.receipt?.seq ?? -1) > seq) continue
|
||||
settlement.receipt = { target, seq, index: start + index, attachments: attachmentRefsIn(message.content) }
|
||||
}
|
||||
}
|
||||
|
||||
private observeSubmissionMessage(message: UserMessage, admitted: boolean): void {
|
||||
const source = message.source
|
||||
if (source.kind !== 'user' || !('rpcId' in source)) return
|
||||
if (!admitted && this.submissionSettlements.get(source.rpcId)?.placement === 'steering') return
|
||||
this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content))
|
||||
const settlement = this.submissionSettlements.get(source.rpcId)
|
||||
if (settlement === undefined || settlement.retiring) return
|
||||
if (!admitted) {
|
||||
if (settlement.placement === 'queued') this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content))
|
||||
return
|
||||
}
|
||||
settlement.admitted = attachmentRefsIn(message.content)
|
||||
this.retireAdmittedSubmission(source.rpcId)
|
||||
}
|
||||
|
||||
/** Retire non-steering echoes when the Inbox accepts their queue occurrences. */
|
||||
/** Retire admitted Chat identities only after stale Inbox rows can no longer reappear. */
|
||||
private retireAdmittedSubmission(requestId: SessionRequestId): void {
|
||||
const settlement = this.submissionSettlements.get(requestId)
|
||||
if (settlement?.admitted === undefined) return
|
||||
const receipt = settlement.receipt
|
||||
if (receipt?.index === null
|
||||
&& (this.projections.seqOf('inbox') ?? -1) < receipt.seq) return
|
||||
this.scheduleObservedRetirement(requestId, settlement.admitted)
|
||||
}
|
||||
|
||||
/** Inbox acceptance retires queued echoes; its watermark completes admitted Chat handoffs. */
|
||||
private observeSubmissionInbox(): void {
|
||||
if (this.submissionSettlements.size === 0) return
|
||||
const inbox = this.projections.get('inbox') as InboxState | undefined
|
||||
if (inbox === undefined) return
|
||||
for (const message of [...inbox['next-turn'], ...inbox['next-step']]) this.observeSubmissionMessage(message, false)
|
||||
const seq = this.projections.seqOf('inbox')
|
||||
for (const target of ['next-turn', 'next-step'] as const) {
|
||||
if (seq !== undefined) this.observeSubmissionInsertions(target, inbox[target], 0, seq)
|
||||
for (const message of inbox[target]) this.observeSubmissionMessage(message, false)
|
||||
}
|
||||
for (const requestId of this.submissionSettlements.keys()) this.retireAdmittedSubmission(requestId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -818,7 +854,7 @@ export class Session implements SessionFace {
|
||||
/** Remove one unsettled echo immediately (prompt rejection, abort, or disposal). */
|
||||
private retireFailedSubmission(requestId: SessionRequestId): void {
|
||||
const settlement = this.submissionSettlements.get(requestId)
|
||||
if (settlement === undefined || settlement.retiring) return
|
||||
if (settlement === undefined || settlement.retiring || settlement.admitted !== undefined) return
|
||||
settlement.retiring = true
|
||||
this.finishSubmission(requestId, { reason: 'failed' })
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ export class SessionEventStream extends RemoteJournalStream<
|
||||
for await (const frame of this.remote.session.follow({
|
||||
address: this.address,
|
||||
assistantStream: true,
|
||||
...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }),
|
||||
...this.repairRequest(request),
|
||||
}, signal)) {
|
||||
if (frame.type === 'snapshot') {
|
||||
for (const record of frame.records) assertSessionWireEvent(record.event)
|
||||
@@ -238,6 +238,9 @@ export class SessionEventStream extends RemoteJournalStream<
|
||||
protected override repairRequest(
|
||||
request: ClientSessionPageRequest,
|
||||
): ClientSessionPageRequest {
|
||||
return request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }
|
||||
return {
|
||||
...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }),
|
||||
...(request.turnWindow === undefined ? {} : { turnWindow: request.turnWindow }),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,7 @@ export class SessionHistoryController {
|
||||
beforeSeq,
|
||||
request.maxMessages ?? DEFAULT_MAX_MESSAGES,
|
||||
throughSeq,
|
||||
request.turnWindow,
|
||||
)
|
||||
const records = pageRecords(page.events)
|
||||
return {
|
||||
@@ -117,7 +118,7 @@ export class SessionHistoryController {
|
||||
* @returns a complete opening snapshot followed by gap-free durable events and opted-in assistant frames.
|
||||
*/
|
||||
async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
|
||||
validateFollowRequest(request)
|
||||
validateHistoryWindow(request)
|
||||
const { address } = request
|
||||
const target = addressId(address)
|
||||
const buffered = new Deque<
|
||||
@@ -180,7 +181,7 @@ export class SessionHistoryController {
|
||||
signal.throwIfAborted()
|
||||
const cursor = source.cursor
|
||||
snapshotCursor = cursor
|
||||
const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
|
||||
const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES, cursor, request.turnWindow)
|
||||
const assistantStream = request.assistantStream === true
|
||||
? this.assistantStreams.get(target)?.snapshot() ?? { revision: 0 }
|
||||
: undefined
|
||||
@@ -313,16 +314,23 @@ function validatePageRequest(request: SessionPageRequest): void {
|
||||
|| Object.is(request.beforeSeq, -0))) {
|
||||
throw new RemoteError('gateway/bad-request', 'beforeSeq must be a non-negative safe integer', {})
|
||||
}
|
||||
validateHistoryWindow(request)
|
||||
}
|
||||
|
||||
function validateHistoryWindow(request: Pick<SessionPageRequest, 'maxMessages' | 'turnWindow'>): void {
|
||||
if (request.maxMessages !== undefined
|
||||
&& (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
|
||||
throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {})
|
||||
}
|
||||
}
|
||||
|
||||
function validateFollowRequest(request: SessionFollowRequest): void {
|
||||
if (request.maxMessages !== undefined
|
||||
&& (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
|
||||
throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {})
|
||||
const window = request.turnWindow
|
||||
if (window !== undefined) {
|
||||
if (!Number.isSafeInteger(window.minMessages) || window.minMessages <= 0
|
||||
|| window.minMessages > (request.maxMessages ?? DEFAULT_MAX_MESSAGES)) {
|
||||
throw new RemoteError('gateway/bad-request', 'turnWindow.minMessages must be a positive safe integer no greater than maxMessages', {})
|
||||
}
|
||||
if (!Number.isSafeInteger(window.minTurns) || window.minTurns <= 0) {
|
||||
throw new RemoteError('gateway/bad-request', 'turnWindow.minTurns must be a positive safe integer', {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,13 +393,22 @@ function paginate(
|
||||
events: readonly SessionEvent[],
|
||||
beforeSeq: SessionLogOffsetType | undefined,
|
||||
maxMessages: number,
|
||||
throughSeq: SessionSeqCursor = events.at(-1)?.seq ?? -1,
|
||||
throughSeq: SessionSeqCursor,
|
||||
turnWindow?: SessionPageRequest['turnWindow'],
|
||||
): { readonly events: SessionEvent[]; readonly hasMore: boolean } {
|
||||
const end = SessionLogOffset(Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1))
|
||||
let count = 0
|
||||
let turns = 0
|
||||
let cut = SessionLogOffset(0)
|
||||
for (let index = end - 1; index >= 0; index--) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (turnWindow !== undefined && event.type === 'turn/start') {
|
||||
turns++
|
||||
if (count >= turnWindow.minMessages && turns >= turnWindow.minTurns) {
|
||||
cut = SessionLogOffset(index)
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
|
||||
count++
|
||||
const sources = event.sourceEventSeqs
|
||||
|
||||
@@ -472,12 +472,18 @@ export interface SessionPageRequest {
|
||||
readonly throughSeq: number
|
||||
readonly beforeSeq?: number
|
||||
readonly maxMessages?: number
|
||||
/** Stop at a Turn start after both minima, unless maxMessages or history exhaustion wins. */
|
||||
readonly turnWindow?: {
|
||||
/** Minimum append-origin user/assistant messages; must not exceed maxMessages. */
|
||||
readonly minMessages: number
|
||||
/** Minimum Turn starts crossed, including the partial Turn at beforeSeq. */
|
||||
readonly minTurns: number
|
||||
}
|
||||
}
|
||||
|
||||
/** One live event request for a durable Session address. */
|
||||
export interface SessionFollowRequest {
|
||||
export interface SessionFollowRequest extends Pick<SessionPageRequest, 'maxMessages' | 'turnWindow'> {
|
||||
readonly address: SessionAddress
|
||||
readonly maxMessages?: number
|
||||
/** Include process-local assistant presentation frames for the Web client. */
|
||||
readonly assistantStream?: true
|
||||
}
|
||||
|
||||
@@ -777,7 +777,8 @@ describe('subagent catalogs', () => {
|
||||
mode: 'continuable',
|
||||
},
|
||||
assistantStream: true,
|
||||
maxMessages: 50,
|
||||
maxMessages: 500,
|
||||
turnWindow: { minMessages: 50, minTurns: 2 },
|
||||
},
|
||||
])
|
||||
expect(mock.log.requests('session/page')).toEqual([])
|
||||
|
||||
@@ -44,6 +44,18 @@ function makeManager(mock: RemoteMock, remote: ClientTestFixtures['remote']): Se
|
||||
}
|
||||
|
||||
describe('Session projection value semantics', () => {
|
||||
it('exposes a watermark only for current Host-sequenced values', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
expect(store.seqOf('inbox')).toBeUndefined()
|
||||
store.applyCached({ inbox: { 'next-turn': [], 'next-step': [] } })
|
||||
expect(store.seqOf('inbox')).toBeUndefined()
|
||||
store.apply('inbox', { 'next-turn': [], 'next-step': [] }, SessionSeq(4))
|
||||
store.apply('inbox', { 'next-turn': [], 'next-step': [] }, SessionSeq(2))
|
||||
expect(store.seqOf('inbox')).toBe(4)
|
||||
store.seed({ asOfSeq: SessionSeq(6), values: {} })
|
||||
expect(store.seqOf('inbox')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads undefined until a value lands (capability absence)', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
expect(store.get('test/marks')).toBeUndefined()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Raw Session journal transport and message-aligned pagination coverage. */
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { type Agent, type AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
@@ -25,9 +25,9 @@ function appendUserText(session: Session, text: string): SessionEvent {
|
||||
}
|
||||
|
||||
/** Append a production-shaped assistant message to the session surface. */
|
||||
function appendAssistantText(session: Session, text: string, step: number): SessionEvent {
|
||||
function appendAssistantText(session: Session, text: string, step: number, turn = 1): SessionEvent {
|
||||
return session.append('assistant/message', {
|
||||
turn: 1,
|
||||
turn,
|
||||
step,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
@@ -748,6 +748,74 @@ describe('Session history raw journal', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'two long Turns', replies: [9, 39, 39], beforeMessage: undefined, firstTurn: 2, steering: false },
|
||||
{ name: 'the partial upper Turn and its predecessor', replies: [9, 39, 99], beforeMessage: 20, firstTurn: 2, steering: false },
|
||||
{ name: 'an upper window beginning at its user message', replies: [9, 59, 59], beforeMessage: 0, firstTurn: 2, steering: false },
|
||||
{ name: 'short Turns until 50 messages', replies: [9, 9, 9, 9, 9, 9], beforeMessage: undefined, firstTurn: 2, steering: false },
|
||||
{ name: 'navigation until 200 messages', replies: [9, 49, 49, 49, 49], beforeMessage: undefined, firstTurn: 2, steering: false, minMessages: 200 },
|
||||
{ name: 'steering within one Turn', replies: [9, 39, 39], beforeMessage: undefined, firstTurn: 2, steering: true },
|
||||
{ name: 'exhausted history below both minima', replies: [9], beforeMessage: undefined, firstTurn: 1, steering: false },
|
||||
{ name: 'the 500-message cap inside one Turn', replies: [9, 600], beforeMessage: undefined, firstTurn: undefined, steering: false },
|
||||
])('paginates $name in one contiguous Turn window', async ({ replies, beforeMessage, firstTurn, steering, minMessages = 50 }) => {
|
||||
const { ctx } = await harness()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
|
||||
const starts: SessionEvent[] = []
|
||||
const messages: SessionEvent[][] = []
|
||||
for (const [index, count] of replies.entries()) {
|
||||
const turn = index + 1
|
||||
starts.push(session.append('turn/start', { turn }))
|
||||
const rows = [appendUserText(session, `prompt ${turn}`)]
|
||||
for (let step = 1; step <= count; step++) {
|
||||
rows.push(appendAssistantText(session, `reply ${turn}/${step}`, step, turn))
|
||||
if (steering && index === replies.length - 1 && step % 3 === 0) {
|
||||
rows.push(appendUserText(session, `steering ${step}`))
|
||||
}
|
||||
}
|
||||
messages.push(rows)
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
const beforeSeq = beforeMessage === undefined ? session.seq : messages.at(-1)![beforeMessage]!.seq
|
||||
const response = await remote.page({
|
||||
address: { kind: 'session', sessionId: session.id },
|
||||
throughSeq: session.seq - 1,
|
||||
beforeSeq,
|
||||
maxMessages: 500,
|
||||
turnWindow: { minMessages, minTurns: 2 },
|
||||
})
|
||||
if (!response.ok) throw new Error('expected a history page')
|
||||
const page = pageEvents(response.value)
|
||||
const expectedStart = firstTurn === undefined ? messages.at(-1)!.at(-500)!.seq : starts[firstTurn - 1]!.seq
|
||||
expect(page.map(event => event.seq)).toEqual(Array.from({ length: beforeSeq - expectedStart }, (_, index) => expectedStart + index))
|
||||
expect(response.value.hasMore).toBe(expectedStart > 0)
|
||||
if (firstTurn === undefined) {
|
||||
expect(page.filter(event => event.type === 'user/message' || event.type === 'assistant/message')).toHaveLength(500)
|
||||
expect(page.some(event => event.type === 'user/message')).toBe(false)
|
||||
} else {
|
||||
expect(page[0]?.type).toBe('turn/start')
|
||||
expect(page.some(event => event.seq === messages[firstTurn - 1]![0]!.seq)).toBe(true)
|
||||
}
|
||||
if (beforeMessage === undefined) {
|
||||
const abort = new AbortController()
|
||||
const stream = remote.follow({
|
||||
address: { kind: 'session', sessionId: session.id },
|
||||
maxMessages: 500,
|
||||
turnWindow: { minMessages, minTurns: 2 },
|
||||
}, abort.signal)[Symbol.asyncIterator]()
|
||||
try {
|
||||
await expect(stream.next()).resolves.toMatchObject({
|
||||
done: false,
|
||||
value: { type: 'snapshot', records: response.value.records, hasMore: response.value.hasMore },
|
||||
})
|
||||
} finally {
|
||||
abort.abort()
|
||||
await stream.return?.()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => {
|
||||
const { ctx } = await harness()
|
||||
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
@@ -105,6 +105,19 @@ describe('beginSubmission', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps rapid ordinary submissions in Chat until the running update arrives', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
for (const text of ['A', 'B', 'C']) session.beginSubmission({ mode: 'queue', text, attachments: [] })
|
||||
expect(session.getSnapshot().pendingSubmissions.map(input => input.placement)).toEqual([
|
||||
'transcript', 'transcript', 'transcript',
|
||||
])
|
||||
session.handleRunning(true)
|
||||
session.beginSubmission({ mode: 'queue', text: 'D', attachments: [] })
|
||||
expect(session.getSnapshot().pendingSubmissions.map(input => input.placement)).toEqual([
|
||||
'transcript', 'transcript', 'transcript', 'queued',
|
||||
])
|
||||
})
|
||||
|
||||
it('abandon retires the echo as failed exactly once', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
const retirements: PendingSubmissionRetirement[] = []
|
||||
@@ -176,7 +189,7 @@ describe('observed retirement', () => {
|
||||
expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
|
||||
})
|
||||
|
||||
it('retires an accepted echo when a claim clears the projection before its notification', async ({ mock, start }) => {
|
||||
it('retains an idle echo when a claim clears the projection before its notification', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
await session.open()
|
||||
const onRetire = vi.fn()
|
||||
@@ -190,10 +203,140 @@ describe('observed retirement', () => {
|
||||
data: { target: 'next-step', start: 0, inserted: [message] },
|
||||
})
|
||||
await settleFrames()
|
||||
expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 2,
|
||||
data: { target: 'next-step', start: 0, removedCount: 1, inserted: [] },
|
||||
})
|
||||
await pushEvent(mock, promptEvent(SessionSeq(2), handle.requestId, refs))
|
||||
await settleFrames()
|
||||
expect(session.getSnapshot().pendingSubmissions).toEqual([])
|
||||
expect(onRetire).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: refs })
|
||||
})
|
||||
|
||||
for (const { mode, target, order } of [
|
||||
{ mode: 'queue', target: 'next-turn', order: 'inbox-first' },
|
||||
{ mode: 'queue', target: 'next-turn', order: 'transcript-first' },
|
||||
{ mode: 'steer', target: 'next-step', order: 'inbox-first' },
|
||||
{ mode: 'steer', target: 'next-step', order: 'transcript-first' },
|
||||
] as const) {
|
||||
it(`keeps a ${mode} Chat identity until admission and the Inbox claim watermark (${order})`, async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
await session.open()
|
||||
session.projections.apply('inbox', { 'next-turn': [], 'next-step': [] }, -1)
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => frames.push(callback))
|
||||
const onRetire = vi.fn()
|
||||
session.handleRunning(mode === 'steer')
|
||||
const handle = session.beginSubmission({ mode, text: 'local input', attachments: [], onRetire })
|
||||
const refs = [imageRef('idle-image'), fileRef('idle-file')]
|
||||
const message = queuedItem(handle.requestId, refs)
|
||||
const queued = { 'next-turn': [], 'next-step': [], [target]: [message] }
|
||||
const empty = { 'next-turn': [], 'next-step': [] }
|
||||
if (order === 'inbox-first') session.projections.apply('inbox', queued, SessionSeq(0))
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1,
|
||||
data: { target, start: 0, inserted: [message] },
|
||||
})
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 2,
|
||||
data: { target, start: 0, removedCount: 1, inserted: [] },
|
||||
})
|
||||
expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
|
||||
expect(frames).toHaveLength(0)
|
||||
if (order === 'inbox-first') session.projections.apply('inbox', empty, SessionSeq(1))
|
||||
await pushEvent(mock, promptEvent(SessionSeq(2), handle.requestId, refs))
|
||||
if (order === 'transcript-first') {
|
||||
await pushEvent(mock, ev.turnEnd(SessionSeq(3), 1))
|
||||
handle.abandon()
|
||||
session.projections.apply('inbox', queued, SessionSeq(0))
|
||||
await Promise.resolve()
|
||||
expect(frames).toHaveLength(0)
|
||||
expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
session.projections.apply('inbox', empty, SessionSeq(1))
|
||||
await Promise.resolve()
|
||||
}
|
||||
expect(frames).toHaveLength(1)
|
||||
frames[0]?.(0)
|
||||
expect(session.getSnapshot().pendingSubmissions).toEqual([])
|
||||
expect(onRetire).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: refs })
|
||||
session.projections.apply('inbox', queued, SessionSeq(0))
|
||||
expect(session.projections.get('inbox')).toEqual(empty)
|
||||
})
|
||||
}
|
||||
|
||||
for (const projectionFirst of [true, false]) {
|
||||
it(`settles overlapping opening, queued and steering inputs independently (${projectionFirst ? 'projection' : 'history'} first)`, async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
await session.open()
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => frames.push(callback))
|
||||
const flush = () => { for (const callback of frames.splice(0)) callback(0) }
|
||||
const normalRetired = vi.fn()
|
||||
const queueRetired = vi.fn()
|
||||
const steerRetired = vi.fn()
|
||||
const normal = session.beginSubmission({ mode: 'queue', text: 'opening', attachments: [], onRetire: normalRetired })
|
||||
session.handleRunning(true)
|
||||
const queued = session.beginSubmission({ mode: 'queue', text: 'queued', attachments: [], onRetire: queueRetired })
|
||||
const steer = session.beginSubmission({ mode: 'steer', text: 'steering', attachments: [], onRetire: steerRetired })
|
||||
const normalMessage = queuedItem(normal.requestId, [fileRef('opening-file')])
|
||||
const queuedMessage = queuedItem(queued.requestId, [imageRef('queued-image')])
|
||||
const steerMessage = queuedItem(steer.requestId, [imageRef('steer-image'), fileRef('steer-file')])
|
||||
const pending = () => session.getSnapshot().pendingSubmissions.map(input => input.text)
|
||||
const accepted = { 'next-turn': [normalMessage, queuedMessage], 'next-step': [steerMessage] }
|
||||
if (projectionFirst) session.projections.apply('inbox', accepted, SessionSeq(1))
|
||||
await pushEvent(mock, { type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1,
|
||||
data: { target: 'next-turn', start: 0, inserted: [normalMessage, queuedMessage] } })
|
||||
await pushEvent(mock, { type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 2,
|
||||
data: { target: 'next-step', start: 0, inserted: [steerMessage] } })
|
||||
flush()
|
||||
expect(pending()).toEqual(['opening', 'steering'])
|
||||
expect(queueRetired).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: [imageRef('queued-image')] })
|
||||
await pushEvent(mock, ev.turnStart(SessionSeq(2), 1))
|
||||
await pushEvent(mock, { type: 'agent/inbox/spliced', seq: SessionSeq(3), time: 4,
|
||||
data: { target: 'next-step', start: 0, removedCount: 1, inserted: [] } })
|
||||
await pushEvent(mock, { type: 'agent/inbox/spliced', seq: SessionSeq(4), time: 5,
|
||||
data: { target: 'next-turn', start: 0, removedCount: 1, inserted: [] } })
|
||||
const claimed = { 'next-turn': [queuedMessage], 'next-step': [] }
|
||||
if (projectionFirst) session.projections.apply('inbox', claimed, SessionSeq(4))
|
||||
await pushEvent(mock, ev.stepStart(SessionSeq(5), 1, 1))
|
||||
await pushEvent(mock, { type: 'user/message', seq: SessionSeq(6), time: 7, surfaceOp: 'append', data: steerMessage })
|
||||
flush()
|
||||
expect(pending()).toEqual(projectionFirst ? ['opening'] : ['opening', 'steering'])
|
||||
expect(normalRetired).not.toHaveBeenCalled()
|
||||
if (!projectionFirst) expect(steerRetired).not.toHaveBeenCalled()
|
||||
await pushEvent(mock, { type: 'user/message', seq: SessionSeq(7), time: 8, surfaceOp: 'append', data: normalMessage })
|
||||
flush()
|
||||
if (!projectionFirst) {
|
||||
expect(pending()).toEqual(['opening', 'steering'])
|
||||
session.projections.apply('inbox', accepted, SessionSeq(1))
|
||||
flush()
|
||||
expect(normalRetired).not.toHaveBeenCalled()
|
||||
session.projections.apply('inbox', claimed, SessionSeq(4))
|
||||
await Promise.resolve()
|
||||
flush()
|
||||
}
|
||||
expect(pending()).toEqual([])
|
||||
expect(normalRetired).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: [fileRef('opening-file')] })
|
||||
expect(steerRetired).toHaveBeenCalledExactlyOnceWith({
|
||||
reason: 'observed', attachments: [imageRef('steer-image'), fileRef('steer-file')],
|
||||
})
|
||||
await pushEvent(mock, ev.stepEnd(SessionSeq(8), 1, 1))
|
||||
await pushEvent(mock, ev.turnEnd(SessionSeq(9), 1))
|
||||
await pushEvent(mock, ev.turnStart(SessionSeq(10), 2))
|
||||
await pushEvent(mock, { type: 'agent/inbox/spliced', seq: SessionSeq(11), time: 12,
|
||||
data: { target: 'next-turn', start: 0, removedCount: 1, inserted: [] } })
|
||||
await pushEvent(mock, ev.stepStart(SessionSeq(12), 2, 2))
|
||||
await pushEvent(mock, { type: 'user/message', seq: SessionSeq(13), time: 14, surfaceOp: 'append', data: queuedMessage })
|
||||
session.projections.apply('inbox', { 'next-turn': [], 'next-step': [] }, SessionSeq(11))
|
||||
flush()
|
||||
expect(queueRetired).toHaveBeenCalledTimes(1)
|
||||
expect(pending()).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
it('keeps a running steer echo through accepted and claimed Inbox states until transcript admission', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
await session.open()
|
||||
@@ -226,44 +369,46 @@ describe('observed retirement', () => {
|
||||
expect(onRetire).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: refs })
|
||||
})
|
||||
|
||||
it('retires canceled or unadmitted steering without removing still-pending input', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
await session.open()
|
||||
session.handleRunning(true)
|
||||
const canceled = vi.fn()
|
||||
const rejected = vi.fn()
|
||||
const first = session.beginSubmission({ mode: 'steer', text: 'cancel', attachments: [], onRetire: canceled })
|
||||
const second = session.beginSubmission({ mode: 'steer', text: 'reject', attachments: [], onRetire: rejected })
|
||||
const unclaimed = session.beginSubmission({ mode: 'steer', text: 'later', attachments: [] })
|
||||
const foreign = createUserMessage({ source: { kind: 'user' }, content: [] })
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1,
|
||||
data: { target: 'next-step', start: 0, inserted: [queuedItem(first.requestId), queuedItem(second.requestId)] },
|
||||
for (const target of ['next-step', 'next-turn'] as const) {
|
||||
it(`retires canceled or unadmitted ${target} input without removing still-pending input`, async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
await session.open()
|
||||
session.handleRunning(target === 'next-step')
|
||||
const canceled = vi.fn()
|
||||
const rejected = vi.fn()
|
||||
const first = session.beginSubmission({ mode: 'steer', text: 'cancel', attachments: [], onRetire: canceled })
|
||||
const second = session.beginSubmission({ mode: 'steer', text: 'reject', attachments: [], onRetire: rejected })
|
||||
const unclaimed = session.beginSubmission({ mode: 'steer', text: 'later', attachments: [] })
|
||||
const foreign = createUserMessage({ source: { kind: 'user' }, content: [] })
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1,
|
||||
data: { target, start: 0, inserted: [queuedItem(first.requestId), queuedItem(second.requestId)] },
|
||||
})
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 2,
|
||||
data: { target, start: 0, inserted: [foreign] },
|
||||
})
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(2), time: 3,
|
||||
data: { target, start: 1, removedCount: 1, inserted: [], outcome: 'canceled' },
|
||||
})
|
||||
expect(canceled).toHaveBeenCalledExactlyOnceWith({ reason: 'failed' })
|
||||
expect(rejected).not.toHaveBeenCalled()
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(3), time: 4,
|
||||
data: { target, start: 0, removedCount: 2, inserted: [] },
|
||||
})
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(4), time: 5,
|
||||
data: { target, start: 0, inserted: [queuedItem(unclaimed.requestId)] },
|
||||
})
|
||||
await pushEvent(mock, {
|
||||
type: 'turn/end', seq: SessionSeq(5), time: 6, data: { turn: 1, reason: { kind: 'blocked' } },
|
||||
})
|
||||
expect(rejected).toHaveBeenCalledExactlyOnceWith({ reason: 'failed' })
|
||||
expect(session.getSnapshot().pendingSubmissions.map(echo => echo.requestId)).toEqual([unclaimed.requestId])
|
||||
})
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 2,
|
||||
data: { target: 'next-step', start: 0, inserted: [foreign] },
|
||||
})
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(2), time: 3,
|
||||
data: { target: 'next-step', start: 1, removedCount: 1, inserted: [], outcome: 'canceled' },
|
||||
})
|
||||
expect(canceled).toHaveBeenCalledExactlyOnceWith({ reason: 'failed' })
|
||||
expect(rejected).not.toHaveBeenCalled()
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(3), time: 4,
|
||||
data: { target: 'next-step', start: 0, removedCount: 2, inserted: [] },
|
||||
})
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(4), time: 5,
|
||||
data: { target: 'next-step', start: 0, inserted: [queuedItem(unclaimed.requestId)] },
|
||||
})
|
||||
await pushEvent(mock, {
|
||||
type: 'turn/end', seq: SessionSeq(5), time: 6, data: { turn: 1, reason: { kind: 'blocked' } },
|
||||
})
|
||||
expect(rejected).toHaveBeenCalledExactlyOnceWith({ reason: 'failed' })
|
||||
expect(session.getSnapshot().pendingSubmissions.map(echo => echo.requestId)).toEqual([unclaimed.requestId])
|
||||
})
|
||||
}
|
||||
|
||||
it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
@@ -316,21 +461,28 @@ describe('observed retirement', () => {
|
||||
expect(session.getSnapshot().pendingSubmissions).toEqual([])
|
||||
})
|
||||
|
||||
for (const pending of [false, true]) {
|
||||
it(`retires confirmed steering on resync with the Host input ${pending ? 'still pending' : 'outside the history window'}`, async ({ mock, start }) => {
|
||||
for (const [target, pending] of [
|
||||
['next-step', false], ['next-step', true], ['next-turn', false], ['next-turn', true],
|
||||
] as const) {
|
||||
it(`retires confirmed ${target} input on resync with the Host input ${pending ? 'still pending' : 'outside the history window'}`, async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
await session.open()
|
||||
session.handleRunning(true)
|
||||
session.handleRunning(target === 'next-step')
|
||||
const onRetire = vi.fn()
|
||||
const handle = session.beginSubmission({ mode: 'steer', text: 'accepted', attachments: [], onRetire })
|
||||
const refs = [imageRef('steer-image'), fileRef('steer-file')]
|
||||
const message = queuedItem(handle.requestId, refs)
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1,
|
||||
data: { target: 'next-step', start: 0, inserted: [message] },
|
||||
})
|
||||
if (target === 'next-turn') {
|
||||
session.projections.apply('inbox', { 'next-turn': [message], 'next-step': [] }, SessionSeq(0))
|
||||
await Promise.resolve()
|
||||
} else {
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1,
|
||||
data: { target, start: 0, inserted: [message] },
|
||||
})
|
||||
}
|
||||
const unconfirmed = session.beginSubmission({ mode: 'steer', text: 'not accepted yet', attachments: [] })
|
||||
const inbox = { 'next-turn': [], 'next-step': pending ? [message] : [] }
|
||||
const inbox = { 'next-turn': [], 'next-step': [], [target]: pending ? [message] : [] }
|
||||
const replacement = {
|
||||
...historyValue([ev.turnEnd(SessionSeq(10), 2)], true),
|
||||
projections: { asOfSeq: 10, values: { inbox } },
|
||||
@@ -430,6 +582,28 @@ describe('observed retirement', () => {
|
||||
})
|
||||
|
||||
describe('disposal', () => {
|
||||
it('settles an admitted transcript as observed when disposed before the Inbox watermark arrives', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
await session.open()
|
||||
const onRetire = vi.fn()
|
||||
const handle = session.beginSubmission({ mode: 'queue', text: 'admitted', attachments: [], onRetire })
|
||||
const refs = [imageRef('held-image')]
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1,
|
||||
data: { target: 'next-turn', start: 0, inserted: [queuedItem(handle.requestId, refs)] },
|
||||
})
|
||||
await pushEvent(mock, {
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 2,
|
||||
data: { target: 'next-turn', start: 0, removedCount: 1, inserted: [] },
|
||||
})
|
||||
await pushEvent(mock, promptEvent(SessionSeq(2), handle.requestId, refs))
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
await session.dispose()
|
||||
await settleFrames()
|
||||
expect(session.getSnapshot().pendingSubmissions).toEqual([])
|
||||
expect(onRetire).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: refs })
|
||||
})
|
||||
|
||||
it('retires unsettled echoes as failed and preserves an already-observed settlement', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
await session.open()
|
||||
|
||||
@@ -13,7 +13,7 @@ import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
|
||||
import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
|
||||
import { JUMP_PAGE_MESSAGES, Session } from '../src/client/sessions/session.ts'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { SessionEventStream } from '../src/client/transport.ts'
|
||||
import type { SessionFollowRequest, SessionPage, SessionPageRequest } from '../src/types.ts'
|
||||
import { entries, ev, historyValue, plainTurn } from './event-script.client.ts'
|
||||
@@ -191,9 +191,9 @@ describe('paging', () => {
|
||||
await session.open()
|
||||
await session.loadOlder()
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(mock.log.requests(FOLLOW)).toHaveLength(1)
|
||||
expect(mock.log.requests(PAGE)).toMatchObject([
|
||||
{ address: ADDRESS, throughSeq: 11, beforeSeq: 6 },
|
||||
expect(mock.log.requests(FOLLOW)).toMatchObject([{ maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }])
|
||||
expect(mock.log.requests(PAGE)).toEqual([
|
||||
{ address: ADDRESS, throughSeq: 11, beforeSeq: 6, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } },
|
||||
])
|
||||
expect(snapshot.hasMore).toBe(false)
|
||||
expect(eventSeqs(session)).toEqual([...older, ...newer].map(event => event.seq))
|
||||
@@ -254,8 +254,8 @@ describe('paging', () => {
|
||||
expect(snapshot.loadingOlder).toBe(false)
|
||||
expect(eventSeqs(session)).toEqual([...oldest, ...middle, ...newest].map(event => event.seq))
|
||||
expect(mock.log.requests(PAGE)).toMatchObject([
|
||||
{ beforeSeq: 12, maxMessages: JUMP_PAGE_MESSAGES },
|
||||
{ beforeSeq: 6, maxMessages: JUMP_PAGE_MESSAGES },
|
||||
{ beforeSeq: 12, maxMessages: 500, turnWindow: { minMessages: 200, minTurns: 2 } },
|
||||
{ beforeSeq: 6, maxMessages: 500, turnWindow: { minMessages: 200, minTurns: 2 } },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -484,7 +484,7 @@ describe('prompt and cancel errors', () => {
|
||||
expect(steered).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(cancelled).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(mock.log.requests(FOLLOW)).toEqual([
|
||||
{ address: { kind: 'subagent', ...CHILD }, assistantStream: true, maxMessages: 50 },
|
||||
{ address: { kind: 'subagent', ...CHILD }, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } },
|
||||
])
|
||||
expect(mock.log.requests(PAGE)).toEqual([])
|
||||
// The prompt mode crosses the wire as the request's delivery.
|
||||
@@ -580,7 +580,7 @@ describe('prompt and cancel errors', () => {
|
||||
expect(mock.log.requests('subagents/prompt')).toMatchObject([CHILD])
|
||||
expect(mock.log.calls('subagents/interruptByParent').map(call => call.args)).toEqual([[SID, PARENT, 'continuable']])
|
||||
expect(mock.log.requests(FOLLOW)).toEqual([
|
||||
{ address: { kind: 'subagent', ...CHILD, mode: 'one-shot' }, assistantStream: true, maxMessages: 50 },
|
||||
{ address: { kind: 'subagent', ...CHILD, mode: 'one-shot' }, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } },
|
||||
])
|
||||
expect(mock.log.requests(PAGE)).toEqual([])
|
||||
expect(mock.log.requests('session/cancel')).toEqual([])
|
||||
|
||||
@@ -542,15 +542,15 @@ describe('Session Client stream adapters', () => {
|
||||
failed: vi.fn(),
|
||||
})
|
||||
|
||||
await stream.open({ maxMessages: 50 })
|
||||
await stream.open({ maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } })
|
||||
await vi.waitFor(() => { expect(changes).toHaveLength(2) })
|
||||
await stream.prepend({ beforeSeq: 2, maxMessages: 50 })
|
||||
await stream.prepend({ beforeSeq: 2, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } })
|
||||
|
||||
expect(remote.followRequests).toEqual([{
|
||||
address: ADDRESS, assistantStream: true, maxMessages: 50,
|
||||
address: ADDRESS, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 },
|
||||
}])
|
||||
expect(remote.pageRequests).toEqual([
|
||||
{ address: ADDRESS, throughSeq: 4, beforeSeq: 2, maxMessages: 50 },
|
||||
{ address: ADDRESS, throughSeq: 4, beforeSeq: 2, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } },
|
||||
])
|
||||
expect(changes).toMatchObject([
|
||||
{ type: 'replace', entries: [entry(2), entry(3)], hasMore: true },
|
||||
@@ -581,12 +581,12 @@ describe('Session Client stream adapters', () => {
|
||||
failed: vi.fn(),
|
||||
})
|
||||
|
||||
await stream.open({ maxMessages: 50 })
|
||||
await stream.open({ maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } })
|
||||
await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) })
|
||||
|
||||
expect(remote.followRequests).toEqual([
|
||||
{ address: ADDRESS, assistantStream: true, maxMessages: 50 },
|
||||
{ address: ADDRESS, assistantStream: true, maxMessages: 50 },
|
||||
{ address: ADDRESS, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } },
|
||||
{ address: ADDRESS, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } },
|
||||
])
|
||||
expect(remote.pageRequests).toEqual([])
|
||||
expect(changes.map(change => change.type)).toEqual(['replace', 'append', 'replace'])
|
||||
@@ -623,7 +623,7 @@ describe('Session Client stream adapters', () => {
|
||||
await stream.dispose()
|
||||
})
|
||||
|
||||
it.each([{}, { maxMessages: 50 }])('repairs a live gap preserving message limit %j', async (request) => {
|
||||
it.each([{}, { maxMessages: 50 }, { maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }])('repairs a live gap preserving history limits %j', async (request) => {
|
||||
const remote = new ScriptedSessionRemote(
|
||||
[{ frames: [snapshot(0, [entry(0)]), entry(2)], hold: true }],
|
||||
[{ ok: true, value: page([entry(0), entry(1), entry(2)]) }],
|
||||
|
||||
@@ -517,6 +517,12 @@ describe('SessionHistoryController', () => {
|
||||
{ address, throughSeq: -1, beforeSeq: 1.5 },
|
||||
{ address, throughSeq: -1, maxMessages: 0 },
|
||||
{ address, throughSeq: -1, maxMessages: 1.5 },
|
||||
{ address, throughSeq: -1, turnWindow: { minMessages: 0, minTurns: 2 } },
|
||||
{ address, throughSeq: -1, turnWindow: { minMessages: 1.5, minTurns: 2 } },
|
||||
{ address, throughSeq: -1, turnWindow: { minMessages: 51, minTurns: 2 } },
|
||||
{ address, throughSeq: -1, maxMessages: 20, turnWindow: { minMessages: 21, minTurns: 2 } },
|
||||
{ address, throughSeq: -1, turnWindow: { minMessages: 50, minTurns: 0 } },
|
||||
{ address, throughSeq: -1, turnWindow: { minMessages: 50, minTurns: 1.5 } },
|
||||
]) {
|
||||
await expect(transport.page(request, signal())).rejects.toMatchObject({ code: 'gateway/bad-request' })
|
||||
}
|
||||
@@ -537,6 +543,16 @@ describe('SessionHistoryController', () => {
|
||||
const iterator = transport.follow({ address, maxMessages }, signal())[Symbol.asyncIterator]()
|
||||
await expect(iterator.next()).rejects.toMatchObject({ code: 'gateway/bad-request' })
|
||||
}
|
||||
for (const turnWindow of [
|
||||
{ minMessages: 0, minTurns: 2 },
|
||||
{ minMessages: 1.5, minTurns: 2 },
|
||||
{ minMessages: 51, minTurns: 2 },
|
||||
{ minMessages: 50, minTurns: 0 },
|
||||
{ minMessages: 50, minTurns: 1.5 },
|
||||
]) {
|
||||
const iterator = transport.follow({ address, turnWindow }, signal())[Symbol.asyncIterator]()
|
||||
await expect(iterator.next()).rejects.toMatchObject({ code: 'gateway/bad-request' })
|
||||
}
|
||||
})
|
||||
|
||||
it('reports missing ordinary and subagent sources without fabricating inspection failures', async () => {
|
||||
|
||||
@@ -193,13 +193,19 @@ it('closes help even when the browser reports no previously focused element', ()
|
||||
it.each([false, true])('only offers a description tooltip when the card clips it: %s', (overflow) => {
|
||||
vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(overflow ? 400 : 80)
|
||||
vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(80)
|
||||
const disconnect = vi.fn()
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect = disconnect })
|
||||
const disconnect = vi.spyOn(ResizeObserver.prototype, 'disconnect')
|
||||
vi.useFakeTimers()
|
||||
view({ rows: [{ id: 'mine', isDefault: false, description: 'Preset description' }] })
|
||||
fireEvent.mouseEnter(screen.getByText('Preset description'))
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
expect(screen.queryByRole('tooltip')?.textContent ?? null).toBe(overflow ? 'Preset description' : null)
|
||||
cleanup()
|
||||
expect(disconnect).toHaveBeenCalledOnce()
|
||||
expect(disconnect).toHaveBeenCalledTimes(overflow ? 2 : 1)
|
||||
})
|
||||
it('renders descriptions and allows selection without resize observation', () => {
|
||||
vi.stubGlobal('ResizeObserver', undefined)
|
||||
const actions = view({ rows: [{ id: 'mine', name: 'Mine', isDefault: false, description: 'Preset description' }] })
|
||||
expect(screen.getByText('Preset description')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: `${en.setDefault}: Mine` }))
|
||||
expect(actions.makeDefault).toHaveBeenCalledWith('mine')
|
||||
})
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
|
||||
README.md: 285242c9dc9b8225711096aed03f183f5a8e36b7
|
||||
README.zh.md: 8844c9c361f7404af4ea22c645f18e2c9c4e1799
|
||||
README.md: 1aa7d71b3f932a7a14ef6d23e9ace7eda444fc3b
|
||||
README.zh.md: 2239234448e1e00fd395634de39a96c2a80a35f7
|
||||
@@ -72,7 +72,9 @@ The completed-turn action footer follows the recorded Turn end. Its action row s
|
||||
<a id="turn-process-folding"></a>
|
||||
## Turn Process Folding
|
||||
|
||||
During uninterrupted following, local steering echoes remain mounted through Inbox acceptance and claim until the durable message arrives, without triggering tail following twice. Pending inputs follow Inbox order across clients, using matching local echoes in place. After reconnect, Host-owned rows replace receipt-confirmed local echoes; a claim awaiting admission may briefly have no bubble.
|
||||
During uninterrupted following, local transcript and steering echoes remain mounted through Inbox acceptance and claim until the durable message arrives, without triggering tail following twice. Pending steering follows Inbox order across clients, using matching local echoes in place. Admitted local steering also suppresses matching stale Inbox rows until the claim projection arrives; steering without a locally tracked submission continues to follow the Inbox projection. After reconnect, Host-owned rows replace receipt-confirmed local echoes; a claim awaiting admission may briefly have no bubble.
|
||||
|
||||
When Chat ends with an open Turn control and that Turn has no visible input, the first local transcript echo precedes the control. Other echoes remain at the flow tail. The control and echoes share one keyed list, so arrival of the control preserves the echo's mounted identity. Durable inputs replace their matching echoes in the same render.
|
||||
|
||||
Work-details modes control process-group display and reasoning previews; eligible completed Turns fold their process without hiding the final answer. The [business-rule reference](src/client/conversation-nodes/README.md#display-modes) contains the mode table, title behavior, whole-Turn eligibility, clocks, and disclosure resets.
|
||||
|
||||
@@ -100,6 +102,19 @@ The Chat-node slot injects a reset-bound `useDisclosure` Hook for reasoning and
|
||||
|
||||
Chat restores semantic anchors across history prepend and renderer remounts, with browser scroll anchoring disabled on its scrollport only while following the tail. Pinned scroll deliveries without reader movement, and reader input that reaches the exact floor, update follow ownership immediately, before subsequent layout changes can invalidate their floor. Other reader movement remains pending until the sampling interval or `scrollend`, even inside the follow threshold, so layout growth cannot erase small scroll gestures. Submitting transcript input or steering immediately restores tail following and clears an older pending reader sample. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer.
|
||||
|
||||
The turn rail and back-to-bottom button sit outside the clipped transcript. They use the shared conversation scrollport for sticky positioning, or the Chat frame for absolute positioning when Chat owns its scrollport. The rail hides when the transcript's available width, excluding its horizontal padding, is at most 900px; the browser viewport width is not the criterion.
|
||||
|
||||
The transcript root uses `overflow-x: visible; overflow-y: clip`: vertical overflow is clipped without creating a scroll container. Sticky Markdown code banners and expanded compaction headers therefore retain the actual conversation scrollport as their reference when no nearer scrolling ancestor exists. Capped process groups and terminal sections keep their own scrollports.
|
||||
|
||||
Outer transcript following and each open capped group's following are independent. Native animation progress retains follow intent; a reader gesture interrupts the animation, and actual movement determines whether following remains enabled. Scroll chaining can move the outer transcript, which then applies its own distance threshold. The back-to-bottom button restores only outer following.
|
||||
|
||||
| Outer follows | Open group follows | Back-to-bottom button | New content |
|
||||
|---|---|---|---|
|
||||
| Yes | Yes | Hidden | Each scrollport follows its own floor. |
|
||||
| Yes | No | Hidden | The outer transcript follows; the group retains its position. |
|
||||
| No | Yes | Visible | The group follows; the outer transcript retains its reading position. |
|
||||
| No | No | Visible | Both retain their reading positions. |
|
||||
|
||||
The turn rail mounts only visible marks, overscan, and the focused mark's neighbors. Its fixed pitch and observed viewport size determine scroll offsets without reading the DOM scroll extent. Initial placement waits for the body's restored active Turn and the rail's first usable viewport size. The ref controls activate a Turn or scroll the rail independently; the transcript itself remains fully mounted.
|
||||
|
||||
While the pointer is outside the rail, automatic follow keeps the rail still when the active mark's center is inside the fade-free band and centers it after it leaves that band. Previews follow pointer movement or focus; marks scrolling under a stationary pointer do not select another preview.
|
||||
@@ -107,7 +122,11 @@ While the pointer is outside the rail, automatic follow keeps the rail still whe
|
||||
<details>
|
||||
<summary>Scroll implementation — click to expand</summary>
|
||||
|
||||
`useChatViewport` owns turn-aware DOM reads, clamped writes, native events, and one retained paging anchor. For Load older, Node and Group seats mark eligible anchors from their existing disclosure state. The viewport selects the first nonempty, unhidden marker in transcript order without hit testing or geometry-based search, then measures that element and its scroll containers. It compensates the anchor's capped group first, then gives the remaining displacement to the transcript scrollport. Commits and later content resizes reuse that anchor; a remounted row is resolved by the same semantic key. Compensation stays within the actual scroll ranges without adding bottom space.
|
||||
Within a process group, wheel, touchstart, and any pointerdown interrupt an active smooth animation, including presses on tool cards. ArrowUp/ArrowDown, PageUp/PageDown, Home/End, and Space keys also interrupt it unless a child has prevented the key's default action; editable controls are not excluded. These events stop the animation without requiring a scroll displacement. Subsequent position sampling determines whether following continues.
|
||||
|
||||
`useScrollFollow` supplies independent controllers for shared bottom thresholds, follow intent, and native scrolling. `useProcessScroll` owns group observation, initial placement, and edge fades. Outer following remains immediate; group growth uses native smooth scrolling unless reduced motion is requested. Growth retains an in-flight target until `scrollend`; arrival at that target or its shrink-clamped position continues toward the latest floor, while another endpoint releases following. Opening placement remains immediate. A bottom-follow request within tolerance uses immediate positioning when no animation is outstanding, so a fractional no-op cannot leave a pending smooth target.
|
||||
|
||||
`useChatViewport` owns turn-aware DOM reads, clamped writes, native events, and one retained paging anchor. For Load older, Node and Group seats mark eligible anchors from their existing disclosure state. The viewport selects the first nonempty, unhidden marker in transcript order without hit testing or geometry-based search, then measures that element and its scroll containers. It compensates the anchor's capped group first, then gives the remaining displacement to the transcript scrollport. An inner compensation write cancels that group's animation through its bound controller and pauses following; reader scrolling back to the bottom resumes it. Commits and later content resizes reuse that anchor; a remounted row is resolved by the same semantic key. Compensation stays within the actual scroll ranges without adding bottom space.
|
||||
|
||||
`useChatReading` owns follow policy, sampled reader input, and semantic memory; `useChatNavigation` owns turn jumps and requests preservation from the viewport. Reading gestures release the paging anchor, but composer clicks, typing, and non-scrolling keys retain it; while a page is still loading, `scrollend` captures the reader's new position. `useChatScroll` coordinates their committed inputs. Explicit navigation carries its measured landing into reading policy, so it does not rediscover the known target with a hit test.
|
||||
|
||||
@@ -133,6 +152,8 @@ None; Chat presentation does not assemble or mutate provider requests.
|
||||
|
||||
- **Developer messages are not displayed** — presentation is intentionally deferred; encountering `developer/message` throws instead of rendering a fallback row.
|
||||
|
||||
- **Opening echoes predict local order** — several submissions made before the running update can all remain in Chat. Their initial order follows local submission order, not Host queue order; admission can reposition them when the Host receives requests in a different order.
|
||||
|
||||
- **The transcript reflects the loaded Session window** — older transcript nodes become available only after Session Controller loads the preceding event page. Turn navigation is wider than the window: the rail merges the loaded Turns with the host `turnOutline` projection, so every started Turn gets a fixed-pitch mark (10px apart; a ladder taller than the frame scrolls inside it with gradient fades), and activating an unloaded mark pages history through the Turn's `turn/start` seq before landing on its row. Without the projection (assemblies not mounting `dsh-session-turn-outline`) the rail falls back to loaded Turns only.
|
||||
- **Rail previews are card-sized** — one prompt line (50 characters) and up to three response lines (120), on loaded and unloaded Turns alike; an unloaded Turn's response arrives from the outline only once the Turn settled, so an open Turn previews its prompt (or just the Turn number) until then.
|
||||
|
||||
|
||||
@@ -72,7 +72,9 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,
|
||||
<a id="turn-process-folding"></a>
|
||||
## 轮次过程折叠
|
||||
|
||||
正常在线时,本地 steering 回显在 Inbox 接受与领取期间保持挂载,直到持久消息到达,不会重复触发跟随底部。跨客户端的待处理输入遵循 Inbox 顺序,在匹配位置使用本地回显。重连后,Host 消息替代已有接收回执的本地回显;已领取但尚未入档时,气泡可能短暂消失。
|
||||
正常在线时,本地 transcript 与 steering 回显在 Inbox 接受与领取期间保持挂载,直到持久消息到达,不会重复触发跟随底部。跨客户端的待处理 steering 遵循 Inbox 顺序,在匹配位置使用本地回显。已入档的本地 steering 还会排除匹配的旧 Inbox 行,直到领取投影到达;没有本地提交身份的 steering 仍按 Inbox 投影显示。重连后,Host 消息替代已有接收回执的本地回显;已领取但尚未入档时,气泡可能短暂消失。
|
||||
|
||||
Chat 末尾为进行中的 Turn 控制行,且该轮尚无可见输入时,第一条本地 transcript 回显显示在控制行前。其他回显保留在正文末尾。控制行与回显共用一个 keyed 列表,因此控制行到达时不会重新挂载回显。持久输入在同一次渲染中替换匹配的回显。
|
||||
|
||||
工作过程展示模式控制过程组显示与推理预览;符合条件的已完成轮次折叠过程,但不隐藏最终答案。[业务规则明细](src/client/conversation-nodes/README.zh.md#display-modes) 统一说明模式表、组头行为、整轮折叠资格、时钟与开合重置。
|
||||
|
||||
@@ -100,6 +102,19 @@ Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure`
|
||||
|
||||
Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且只在跟随底部时禁用自己滚动区域的浏览器自动锚定。没有读者移动的贴底滚动事件,以及确实到达最底部的读者输入,会立即更新跟随归属,避免后续布局变化使其底部位置失效。其他读者移动即使位于跟随阈值内,也保持待处理直到采样周期或 `scrollend`,防止布局增长抵消小幅滚动操作。提交正文输入或 steering 时立即恢复跟随底部,并清除先前待处理的读者滚动采样。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载轮次;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃轮次。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。
|
||||
|
||||
轮次轨道与回到底部按钮位于正文裁剪层外。它们相对共享会话滚动容器做 sticky 定位;Chat 自己持有滚动容器时,则相对 Chat 外框做 absolute 定位。正文扣除水平内边距后的可用宽度不超过 900px 时,隐藏轮次轨道;判断依据不是浏览器视口宽度。
|
||||
|
||||
正文根节点使用 `overflow-x: visible; overflow-y: clip`:裁剪纵向溢出,但不创建滚动容器。因此,在没有更近的滚动祖先时,Markdown 代码块的 sticky 头栏和展开的压缩摘要头栏仍以实际会话滚动容器为参照。限高过程组和终端区域保留各自的滚动容器。
|
||||
|
||||
外层文本记录与每个已展开限高组的跟随状态彼此独立。原生动画的中间滚动保留跟随意图;读者手势中断动画,是否继续跟随由实际位移决定。滚动传递可以带动外层,外层再按自身离底距离判断。回到底部按钮只恢复外层跟随。
|
||||
|
||||
| 外层跟随 | 已展开小组跟随 | 回到底部按钮 | 新增内容 |
|
||||
|---|---|---|---|
|
||||
| 开启 | 开启 | 隐藏 | 各自跟随自己的底部。 |
|
||||
| 开启 | 关闭 | 隐藏 | 外层跟随,小组保持原位置。 |
|
||||
| 关闭 | 开启 | 显示 | 小组跟随,外层保持阅读位置。 |
|
||||
| 关闭 | 关闭 | 显示 | 两者都保持阅读位置。 |
|
||||
|
||||
轮次轨道只挂载可见刻度、预加载范围及键盘焦点刻度的相邻项。固定间距和观察到的视口尺寸决定滚动偏移,不读取 DOM 滚动总高度。初始定位等待正文恢复活跃轮次,以及轨道首次获得可用视口尺寸。ref 控制接口分别支持激活轮次和仅滚动轨道;正文自身仍完整挂载。
|
||||
|
||||
指针位于轨道外时,自动跟随在活跃刻度中心处于非渐隐区域内时保持轨道不动,越界后才将其居中。预览随指针移动或焦点切换;刻度在静止指针下滚动不会选中另一个预览。
|
||||
@@ -107,7 +122,11 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
|
||||
<details>
|
||||
<summary>滚动实现——点击展开</summary>
|
||||
|
||||
`useChatViewport` 负责识别轮次的 DOM 读取、限制范围后的写入、原生事件及一个持续保留的分页锚点。加载更早时,Node 与 Group 容器根据已有开合状态标记可选锚点。视口按正文顺序选择第一个非空、未隐藏的标记,不做命中测试或按几何位置搜索,再测量该元素及其滚动容器。先补偿锚点所在的限高组,再把剩余位移交给整段文本的滚动区域。提交和后续内容尺寸变化复用该锚点;消息行重挂载时按同一语义 key 重新定位。补偿限于实际可滚动范围,不增加底部空白。
|
||||
过程组内的 wheel、touchstart 和任意 pointerdown 都会中断正在进行的平滑动画,包括按下工具卡片。上下方向键、PageUp/PageDown、Home/End 和空格键也会中断动画,除非子控件已阻止该按键的默认行为;输入控件不被排除。这些事件无需产生滚动位移就会停止动画,之后的位置采样再决定是否继续跟随。
|
||||
|
||||
`useScrollFollow` 提供各自独立的控制器,共用触底阈值判断、跟随意图与原生滚动实现。`useProcessScroll` 负责小组观察、初始定位及边缘渐隐。外层保持即时跟随;组内增长使用原生平滑滚动,减少动态效果偏好开启时改为即时滚动。`scrollend` 前的增长保留当前动画目标;到达该目标或因内容缩短而钳制的位置后,再追随最新底部;停在其他位置则释放跟随。展开时仍即时定位。没有进行中动画时,容差范围内的贴底请求使用即时定位,避免小数位置上的无位移操作留下未结束的平滑目标。
|
||||
|
||||
`useChatViewport` 负责识别轮次的 DOM 读取、限制范围后的写入、原生事件及一个持续保留的分页锚点。加载更早时,Node 与 Group 容器根据已有开合状态标记可选锚点。视口按正文顺序选择第一个非空、未隐藏的标记,不做命中测试或按几何位置搜索,再测量该元素及其滚动容器。先补偿锚点所在的限高组,再把剩余位移交给整段文本的滚动区域。内层补偿写入通过该组绑定的控制器取消动画并暂停跟随,读者滚回底部后恢复。提交和后续内容尺寸变化复用该锚点;消息行重挂载时按同一语义 key 重新定位。补偿限于实际可滚动范围,不增加底部空白。
|
||||
|
||||
`useChatReading` 负责跟随策略、读者输入采样和语义位置记忆;`useChatNavigation` 负责轮次跳转,并向视口请求位置保持。阅读手势释放分页锚点,composer 内的点击、打字和非滚动按键则保留锚点;分页仍在加载时,`scrollend` 会记录读者的新位置。`useChatScroll` 协调已提交的输入。显式导航把测得的落点交给阅读策略,因此无需通过命中测试重新寻找已知目标。
|
||||
|
||||
@@ -133,6 +152,8 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
|
||||
|
||||
- **不展示 developer 消息** — 展示能力有意留待后续实现;遇到 `developer/message` 时抛出错误,不渲染回退行。
|
||||
|
||||
- **开场回显预测本地顺序**——运行状态更新前连续发出的多条消息可能都留在 Chat。初始排列遵循本地提交顺序,而非 Host 队列顺序;Host 按不同顺序接收请求时,入档可能调整它们的位置。
|
||||
|
||||
- **transcript 只反映已加载的 Session 窗口**——只有会话控制器加载前一页事件后,更早的 transcript node 才会出现。轮次导航比窗口更宽:轨道把已加载的轮次与宿主 `turnOutline` 投影合并,每个已开始的轮次都有固定间距刻度(相隔 10px;阶梯高于外框时在框内滚动并以渐变淡出标示可滚方向),激活未加载刻度会先把历史分页拉到该轮次的 `turn/start` seq 再落到它的行上。没有该投影时(未挂载 `dsh-session-turn-outline` 的装配),轨道回退到仅显示已加载轮次。
|
||||
- **导航预览按卡片尺寸截断**——提示词一行(50 字符)、回复至多三行(120 字符),已加载与未加载 Turn 一致;未加载 Turn 的回复要等该轮落定后才随大纲到达,进行中的轮次在此之前只预览提示词(或仅轮次号)。
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Stable process container; display policy changes visibility, never member parents. */
|
||||
import { memo, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react'
|
||||
import { memo, useCallback, useEffect, useId, useRef, useState, type ComponentProps, type ReactNode } from 'react'
|
||||
import {
|
||||
IconAgentPresetOutlineRegular, IconApiOutlineRegular, IconBrowseOutlineRegular, IconChevronDownOutlineRegular,
|
||||
IconChevronUpOutlineRegular, IconCodeOutlineRegular, IconEditOutlineRegular, IconGlobeOutlineRegular,
|
||||
@@ -16,6 +16,7 @@ import { chatRenderKey } from './render-entry.ts'
|
||||
import { processTitle } from './step-process.ts'
|
||||
import { useSearchableHidden } from './searchable-hidden.ts'
|
||||
import { useDisclosure } from './use-disclosure.ts'
|
||||
import { useProcessScroll } from './use-process-scroll.ts'
|
||||
import css from './ChatGroupSeat.module.css'
|
||||
|
||||
type SeatProps = Omit<ComponentProps<typeof ChatNodeSeat>, 'nodeKey' | 'groupPart'>
|
||||
@@ -23,8 +24,6 @@ type ChatGroupSeatProps = SeatProps & {
|
||||
readonly groupKey: GroupKey
|
||||
readonly useChatGroup: ChatViewSlotProps['useChatGroup']
|
||||
}
|
||||
interface ProcessScrollEdges { readonly canScrollUp: boolean; readonly canScrollDown: boolean }
|
||||
const PROCESS_SCROLL_AT_REST: ProcessScrollEdges = { canScrollUp: false, canScrollDown: false }
|
||||
const PROCESS_TITLE_MINIMUM_MS = 150
|
||||
|
||||
type ProcessTitleActivity = ProcessActivity | 'thinking'
|
||||
@@ -49,17 +48,6 @@ const PROCESS_ICONS: Record<ProcessTitleActivity, ReactNode> = {
|
||||
tools: <IconSparkleRegular size={14} />,
|
||||
}
|
||||
|
||||
function processScrollEdges(element: HTMLElement): ProcessScrollEdges {
|
||||
return {
|
||||
canScrollUp: element.scrollTop > 1,
|
||||
canScrollDown: element.scrollTop < element.scrollHeight - element.clientHeight - 1,
|
||||
}
|
||||
}
|
||||
|
||||
function sameProcessScrollEdges(left: ProcessScrollEdges, right: ProcessScrollEdges): boolean {
|
||||
return left.canScrollUp === right.canScrollUp && left.canScrollDown === right.canScrollDown
|
||||
}
|
||||
|
||||
function sameLiveProcessTitle(left: LiveProcessTitle, right: LiveProcessTitle): boolean {
|
||||
return left.activity === right.activity && left.detail === right.detail
|
||||
}
|
||||
@@ -135,8 +123,9 @@ const ProcessGroupHeader = memo(function ProcessGroupHeader({ groupKey, useChatG
|
||||
export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGroup, ...props }: ChatGroupSeatProps) {
|
||||
const members = useChatGroup(groupKey, group => group?.members)
|
||||
const turn = useChatGroup(groupKey, group => group?.data.turn)
|
||||
const closed = useChatGroup(groupKey, group => group?.data.closed)
|
||||
const foldCompleted = props.usePresentation(policy => policy.foldCompletedTurns)
|
||||
const { expanded: open, setExpanded: setOpen, toggle } = useDisclosure()
|
||||
const { expanded: open, setExpanded: setOpen } = useDisclosure()
|
||||
const firstKey = members?.[0]?.key ?? ''
|
||||
const presentation = props.useChatNodeProcess(firstKey)
|
||||
const turnLocation = props.useChatNode(firstKey, (node) => {
|
||||
@@ -164,22 +153,11 @@ export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGrou
|
||||
const bodyRef = useSearchableHidden(grouped && !open, reveal)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const bodyId = useId()
|
||||
const [edges, setEdges] = useState<ProcessScrollEdges>(PROCESS_SCROLL_AT_REST)
|
||||
const sync = useCallback(() => {
|
||||
const body = bodyRef.current
|
||||
const next = body === null || body.closest('[hidden], [data-group-expanded-mode]') !== null
|
||||
? PROCESS_SCROLL_AT_REST : processScrollEdges(body)
|
||||
setEdges(previous => sameProcessScrollEdges(previous, next) ? previous : next)
|
||||
}, [bodyRef])
|
||||
// The content box reports growth even when the scroll body remains height-capped.
|
||||
useLayoutEffect(() => {
|
||||
const body = bodyRef.current
|
||||
if (body === null || !open || typeof ResizeObserver === 'undefined') return
|
||||
const observer = new ResizeObserver(sync)
|
||||
observer.observe(body)
|
||||
if (contentRef.current !== null) observer.observe(contentRef.current)
|
||||
return () => { observer.disconnect() }
|
||||
}, [bodyRef, open, sync])
|
||||
const { edges, events, initialize } = useProcessScroll(bodyRef, contentRef, open, grouped)
|
||||
const toggle = useCallback(() => {
|
||||
if (!open) initialize(closed === false ? 'bottom' : 'top')
|
||||
setOpen(!open)
|
||||
}, [closed, initialize, open, setOpen])
|
||||
if (members === undefined) return null
|
||||
const classes = [css.body, !grouped ? css.expandedBody : '',
|
||||
grouped && edges.canScrollUp ? css.fadeTop : '', grouped && edges.canScrollDown ? css.fadeBottom : '']
|
||||
@@ -194,7 +172,7 @@ export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGrou
|
||||
</div>
|
||||
<div ref={bodyRef} id={bodyId} className={classes.join(' ')} data-step-process-body
|
||||
data-scroll-up={edges.canScrollUp || undefined} data-scroll-down={edges.canScrollDown || undefined}
|
||||
onScroll={sync}>
|
||||
{...events}>
|
||||
<div ref={contentRef} className={css.content} data-step-process-content data-chat-flow="">
|
||||
<GroupMembers {...props} members={members} />
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
.frame {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
/* Floating controls query this frame outside the clipped transcript. */
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
|
||||
overflow-x: visible;
|
||||
overflow-y: clip;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
@@ -25,6 +38,7 @@
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
:global([data-conversation-scroll]) .frame,
|
||||
:global([data-conversation-scroll]) .root {
|
||||
flex: 0 0 auto;
|
||||
min-height: auto;
|
||||
@@ -113,12 +127,13 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Back-to-bottom: zero-height sticky slot so the control does not extend
|
||||
scrollHeight; the button translates up into the viewport. Under the
|
||||
conversation host, clearance sits above the sticky composer stack. */
|
||||
/* Floating controls sit outside the clipped transcript. A standalone Chat
|
||||
anchors them to its frame; the shared host supplies the sticky scrollport. */
|
||||
.toBottomSlot {
|
||||
position: sticky;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 16px;
|
||||
left: 0;
|
||||
/* Above the sticky composer (z-index 7) so the control stays clickable and
|
||||
visible over the input card. Exception: while an @/slash menu is open the
|
||||
seat lifts to 9 (ui-conversation ConversationRoot) so the menu is never
|
||||
@@ -127,11 +142,15 @@
|
||||
height: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-right: max(0px, calc((100% - var(--dsh-chat-content-width)) / 2));
|
||||
padding-right: max(
|
||||
calc(var(--dsh-composer-side-clearance) + 16px),
|
||||
calc((100% - var(--dsh-chat-content-width)) / 2)
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global([data-conversation-scroll]) .toBottomSlot {
|
||||
position: sticky;
|
||||
/* Clears the sticky composer stack (docks + input card + stats): the live
|
||||
height rides --dsh-composer-height (ConversationRoot's seat observer) so
|
||||
the control follows a growing textarea; the fallback covers the first
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
NodeKey, RenderEntry, RenderMessageImages,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
|
||||
import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import {
|
||||
Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -28,40 +29,40 @@ function openFailureMessage(error: unknown, fallback: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt-RPC identities already rendered by durable material: user/steering
|
||||
* node sources plus queue occurrences. A submission echo whose identity
|
||||
* appears here is hidden in the same render, so the echo→durable swap is
|
||||
* atomic — no duplicate, no gap — regardless of when the echo leaves the
|
||||
* session snapshot.
|
||||
* Durable input identities suppress matching echoes in the same render.
|
||||
* The last input's Turn also distinguishes an empty opening control from
|
||||
* one whose human input or trigger notice is already present.
|
||||
*/
|
||||
function observedRpcIds(
|
||||
function observedInputs(
|
||||
order: readonly string[],
|
||||
nodes: ChatSnapshot['nodes'],
|
||||
inbox: InboxState | undefined,
|
||||
): { readonly durable: ReadonlySet<string>; readonly pending: ReadonlySet<string> } {
|
||||
): { readonly rpcIds: ReadonlySet<string>; readonly lastInputTurn: number | undefined } {
|
||||
const observed = new Set<string>()
|
||||
let lastInputTurn: number | undefined
|
||||
for (const key of order) {
|
||||
const node = nodes.get(key)
|
||||
if (node === undefined || (node.kind !== 'user' && node.kind !== 'steering')) continue
|
||||
if (node === undefined || (node.kind !== 'user' && node.kind !== 'steering' && node.kind !== 'turn-trigger')) 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 pending = new Set<string>()
|
||||
for (const { source } of [...inbox?.['next-turn'] ?? [], ...inbox?.['next-step'] ?? []]) {
|
||||
if (source.kind === 'user' && 'rpcId' in source) pending.add(source.rpcId)
|
||||
}
|
||||
return { durable: observed, pending }
|
||||
return { rpcIds: observed, lastInputTurn }
|
||||
}
|
||||
|
||||
type PendingInput = PendingSubmission | InboxState['next-step'][number]
|
||||
|
||||
type ChatNodeListProps = Omit<ComponentProps<typeof ChatNodeSeat>, 'nodeKey' | 'groupPart'> & {
|
||||
readonly entries: readonly RenderEntry[]
|
||||
readonly useChatGroup: ChatViewSlotProps['useChatGroup']
|
||||
readonly pendingInputs: readonly PendingInput[]
|
||||
readonly lastInputTurn: number | undefined
|
||||
}
|
||||
|
||||
const ChatNodeList = memo(function ChatNodeList({ entries, useChatGroup, ...seatProps }: ChatNodeListProps) {
|
||||
return entries.map((entry) => {
|
||||
const ChatNodeList = memo(function ChatNodeList({ entries, useChatGroup, pendingInputs, lastInputTurn, ...seatProps }: ChatNodeListProps) {
|
||||
const rows = entries.map((entry) => {
|
||||
switch (entry.kind) {
|
||||
case 'node':
|
||||
return <ChatNodeSeat {...seatProps} key={chatRenderKey(entry)} nodeKey={entry.key}
|
||||
@@ -72,6 +73,23 @@ const ChatNodeList = memo(function ChatNodeList({ entries, useChatGroup, ...seat
|
||||
return assertNever(entry)
|
||||
}
|
||||
})
|
||||
const pendingRows = pendingInputs.map(item => 'requestId' in item ? (
|
||||
<PendingSubmissionBubble key={item.requestId} submission={item}
|
||||
renderMessageImages={seatProps.renderMessageImages} t={seatProps.t} />
|
||||
) : (
|
||||
<PendingSteeringBubble key={item.id} content={item.content}
|
||||
renderMessageImages={seatProps.renderMessageImages} t={seatProps.t} />
|
||||
))
|
||||
const tail = entries.at(-1)
|
||||
const node = tail?.kind === 'node' ? seatProps.nodeStore.get(tail.key) : undefined
|
||||
// An empty opening control follows one local transcript echo, never steering.
|
||||
// All rows share this keyed list so inserting the control keeps the echo mounted.
|
||||
if (node?.kind === 'turn-process' && node.location.kind === 'turn'
|
||||
&& node.location.turn.status === 'open' && node.location.turn.turn !== lastInputTurn) {
|
||||
const index = pendingInputs.findIndex(item => 'requestId' in item && item.placement === 'transcript')
|
||||
if (index !== -1) rows.splice(rows.length - 1, 0, ...pendingRows.splice(index, 1))
|
||||
}
|
||||
return [...rows, ...pendingRows]
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -150,26 +168,28 @@ export function ChatView({
|
||||
// Submission echoes still awaiting their durable counterpart. `order` is the
|
||||
// recompute trigger: durable user material always arrives as an append, and
|
||||
// every append replaces the order array.
|
||||
const visibleSubmissions = useMemo(() => {
|
||||
if (pendingSubmissions.length === 0) return pendingSubmissions
|
||||
const observed = observedRpcIds(order, nodeStore, inbox)
|
||||
return pendingSubmissions.filter(submission => (
|
||||
submission.placement !== 'queued' && !observed.durable.has(submission.requestId)
|
||||
&& (submission.placement === 'steering' || !observed.pending.has(submission.requestId))
|
||||
))
|
||||
}, [pendingSubmissions, order, nodeStore, inbox])
|
||||
const [visibleSubmissions, lastInputTurn] = useMemo(() => {
|
||||
if (pendingSubmissions.length === 0) return [pendingSubmissions, undefined] as const
|
||||
const observed = observedInputs(order, nodeStore)
|
||||
return [pendingSubmissions.filter(submission => (
|
||||
submission.placement !== 'queued' && !observed.rpcIds.has(submission.requestId)
|
||||
)), observed.lastInputTurn] as const
|
||||
}, [pendingSubmissions, order, nodeStore])
|
||||
const pendingInputs = useMemo(() => {
|
||||
const local = new Map(visibleSubmissions.map(submission => [submission.requestId, submission]))
|
||||
const pending = inboxSteering.map((item) => {
|
||||
// Admitted local identities outlive their bubbles until the Inbox claim watermark.
|
||||
const localIds = new Set(pendingSubmissions.filter(submission => submission.placement !== 'queued')
|
||||
.map(submission => submission.requestId))
|
||||
const pending = inboxSteering.flatMap<PendingInput>((item) => {
|
||||
const source = item.source
|
||||
if (source.kind !== 'user' || !('rpcId' in source)) return item
|
||||
if (source.kind !== 'user' || !('rpcId' in source)) return [item]
|
||||
const submission = local.get(source.rpcId)
|
||||
if (submission === undefined) return item
|
||||
if (submission === undefined) return localIds.has(source.rpcId) ? [] : [item]
|
||||
local.delete(source.rpcId)
|
||||
return submission
|
||||
return [submission]
|
||||
})
|
||||
return [...pending, ...local.values()]
|
||||
}, [inboxSteering, visibleSubmissions])
|
||||
}, [inboxSteering, pendingSubmissions, visibleSubmissions])
|
||||
const renderMessageImages = useCallback<RenderMessageImages>(
|
||||
owner => renderSlot('conversation.message.images', { ...owner, loadImage }),
|
||||
[loadImage, renderSlot],
|
||||
@@ -191,85 +211,74 @@ export function ChatView({
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={css.root} data-chat-following-tail={scroll.followingTail ? '' : undefined}>
|
||||
<div ref={scroll.listRef} className={css.scroll}>
|
||||
{scroll.initialized && (
|
||||
<TurnNavigator
|
||||
items={railItems}
|
||||
activeTurn={scroll.activeTurn}
|
||||
busyTurn={scroll.busyTurn}
|
||||
onNavigate={scroll.navigateToTurn}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
<div ref={scroll.columnRef} className={css.column} data-chat-flow="">
|
||||
{openState === 'loading' && <div className={css.hint}>{t('chat.loadingHistory')}</div>}
|
||||
{openState === 'error' && openError !== null && (
|
||||
<div className={css.openError}>
|
||||
{t('chat.loadError', { message: openError.message, code: openError.code })}
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<div className={css.older}>
|
||||
<button type="button" disabled={loadingOlder} onClick={scroll.loadEarlier}>
|
||||
{loadingOlder ? t('loading') : t('chat.loadOlder')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<MarkdownDelegateProvider openExternalLink={openExternalLink} openFile={requestOpenFile}>
|
||||
<ChatNodeList
|
||||
entries={entries}
|
||||
nodeStore={nodeStore}
|
||||
useChatGroup={useChatGroup}
|
||||
useChatNode={useChatNode}
|
||||
useChatNodeProcess={useChatNodeProcess}
|
||||
usePresentation={usePresentation}
|
||||
useStore={useStore}
|
||||
actions={actions}
|
||||
cwd={cwd}
|
||||
openFile={requestOpenFile}
|
||||
openSkill={openSkill}
|
||||
inspectCall={inspectCall}
|
||||
forkAt={forkAt}
|
||||
loadImage={loadImage}
|
||||
renderMessageImages={renderMessageImages}
|
||||
fileMentions={fileMentions}
|
||||
renderSlot={renderSlot}
|
||||
t={t}
|
||||
/>
|
||||
</MarkdownDelegateProvider>
|
||||
{/* No pending placeholders: questions (ui-user-questions) and approvals
|
||||
(ApprovalPanel) both take over the composer, so a flow card would
|
||||
double-render the same wait. */}
|
||||
{pendingInputs.map(item => 'requestId' in item ? (
|
||||
<PendingSubmissionBubble
|
||||
key={item.requestId}
|
||||
submission={item}
|
||||
renderMessageImages={renderMessageImages}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<PendingSteeringBubble
|
||||
key={item.id}
|
||||
content={item.content}
|
||||
renderMessageImages={renderMessageImages}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!scroll.followingTail && (
|
||||
<div className={css.toBottomSlot}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.toBottom}
|
||||
aria-label={t('chat.toBottom')}
|
||||
onClick={scroll.returnToBottom}
|
||||
>
|
||||
<IconChevronDownOutlineRegular />
|
||||
</button>
|
||||
<div className={css.frame}>
|
||||
{scroll.initialized && (
|
||||
<TurnNavigator
|
||||
items={railItems}
|
||||
activeTurn={scroll.activeTurn}
|
||||
busyTurn={scroll.busyTurn}
|
||||
onNavigate={scroll.navigateToTurn}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
<div className={css.root} data-chat-following-tail={scroll.followingTail ? '' : undefined}>
|
||||
<div ref={scroll.listRef} className={css.scroll}>
|
||||
<div ref={scroll.columnRef} className={css.column} data-chat-flow="">
|
||||
{openState === 'loading' && <div className={css.hint}>{t('chat.loadingHistory')}</div>}
|
||||
{openState === 'error' && openError !== null && (
|
||||
<div className={css.openError}>
|
||||
{t('chat.loadError', { message: openError.message, code: openError.code })}
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<div className={css.older}>
|
||||
<button type="button" disabled={loadingOlder} onClick={scroll.loadEarlier}>
|
||||
{loadingOlder ? t('loading') : t('chat.loadOlder')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<MarkdownDelegateProvider openExternalLink={openExternalLink} openFile={requestOpenFile}>
|
||||
<ChatNodeList
|
||||
entries={entries}
|
||||
pendingInputs={pendingInputs}
|
||||
lastInputTurn={lastInputTurn}
|
||||
nodeStore={nodeStore}
|
||||
useChatGroup={useChatGroup}
|
||||
useChatNode={useChatNode}
|
||||
useChatNodeProcess={useChatNodeProcess}
|
||||
usePresentation={usePresentation}
|
||||
useStore={useStore}
|
||||
actions={actions}
|
||||
cwd={cwd}
|
||||
openFile={requestOpenFile}
|
||||
openSkill={openSkill}
|
||||
inspectCall={inspectCall}
|
||||
forkAt={forkAt}
|
||||
loadImage={loadImage}
|
||||
renderMessageImages={renderMessageImages}
|
||||
fileMentions={fileMentions}
|
||||
renderSlot={renderSlot}
|
||||
t={t}
|
||||
/>
|
||||
</MarkdownDelegateProvider>
|
||||
{/* No pending placeholders: questions (ui-user-questions) and approvals
|
||||
(ApprovalPanel) both take over the composer, so a flow card would
|
||||
double-render the same wait. */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!scroll.followingTail && (
|
||||
<div className={css.toBottomSlot}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.toBottom}
|
||||
aria-label={t('chat.toBottom')}
|
||||
onClick={scroll.returnToBottom}
|
||||
>
|
||||
<IconChevronDownOutlineRegular />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{fileOpenError !== null && (
|
||||
<FileOpenErrorDialog
|
||||
message={fileOpenError.message}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
/* Zero-height sticky slot, like the back-to-bottom control: the rail floats
|
||||
over the transcript's right gutter without extending scrollHeight. */
|
||||
/* The rail is a sibling of the clipped transcript, not its descendant. */
|
||||
.slot {
|
||||
position: sticky;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
/* Above transcript CodeBlock banners (z-index 6). The rail band excludes
|
||||
the composer, so its preview never needs to cross the footer. */
|
||||
z-index: 7;
|
||||
height: 0;
|
||||
/* Query the transcript's usable width while keeping the rail full-bleed. */
|
||||
padding-inline: calc(var(--dsh-composer-side-clearance) + 16px);
|
||||
container-type: inline-size;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global([data-conversation-scroll]) .slot {
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.frame {
|
||||
/* The band a reader actually sees: the scrollport minus the sticky composer
|
||||
stack covering its floor. ConversationRoot publishes both measurements on
|
||||
@@ -22,9 +30,7 @@
|
||||
|
||||
position: absolute;
|
||||
top: calc(var(--turn-rail-band) / 2);
|
||||
/* Flush with the scrollport edge: the slot sits inside the transcript's side
|
||||
padding, so the rail gives that inset back and keeps 12px of its own. */
|
||||
right: calc(12px - (var(--dsh-composer-side-clearance) + 16px));
|
||||
right: 12px;
|
||||
width: 28px;
|
||||
/* Fixed-pitch marks never compress: a ladder taller than the band scrolls
|
||||
inside this frame instead of pushing marks under the composer or above
|
||||
@@ -198,7 +204,7 @@
|
||||
}
|
||||
|
||||
@container (max-width: 900px) {
|
||||
.slot {
|
||||
.frame {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** Follow-tail ownership, saved-position restoration, and sampled reader movement. */
|
||||
import { useLayoutEffect, useState } from 'react'
|
||||
import type { ChatScrollPosition, ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { ChatViewport, ViewportLanding, ViewportMetrics, ViewportScroll } from './use-chat-viewport.ts'
|
||||
import type { ChatViewport, ViewportLanding, ViewportScroll } from './use-chat-viewport.ts'
|
||||
import { useScrollFollow, type ScrollFollow } from './use-scroll-follow.ts'
|
||||
|
||||
const FOLLOW_THRESHOLD = 24
|
||||
const SCROLL_SAMPLE_INTERVAL_MS = 500
|
||||
@@ -33,6 +34,7 @@ export class ChatReading {
|
||||
private store: PositionStore,
|
||||
private state: ChatReadingState,
|
||||
private readonly onChange: (state: ChatReadingState) => void,
|
||||
private readonly follow: ScrollFollow,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -76,7 +78,7 @@ export class ChatReading {
|
||||
|
||||
/** Land at the current floor and clear saved reader position. */
|
||||
followTail(): void {
|
||||
const landing = this.viewport.scrollToBottom()
|
||||
const landing = this.viewport.scrollToBottom(this.follow)
|
||||
if (landing === null) return
|
||||
this.cancelPending()
|
||||
this.commit(landing, true, this.viewport.latestTurn)
|
||||
@@ -89,7 +91,7 @@ export class ChatReading {
|
||||
const landing = this.viewport.restore(saved)
|
||||
if (landing === null) return
|
||||
this.cancelPending()
|
||||
const following = this.nearBottom(landing.metrics)
|
||||
const following = this.follow.nearBottom(landing.metrics)
|
||||
this.commit(landing, following, following ? this.viewport.latestTurn : this.state.activeTurn, following)
|
||||
if (!this.state.followingTail && landing.position === null) {
|
||||
const position = this.viewport.capturePosition()
|
||||
@@ -104,7 +106,7 @@ export class ChatReading {
|
||||
*/
|
||||
acceptNavigation(landing: ViewportLanding): void {
|
||||
this.cancelPending()
|
||||
const following = this.nearBottom(landing.metrics)
|
||||
const following = this.follow.nearBottom(landing.metrics)
|
||||
this.commit(landing, following, landing.turn ?? (following ? this.viewport.latestTurn : this.state.activeTurn))
|
||||
}
|
||||
|
||||
@@ -153,10 +155,6 @@ export class ChatReading {
|
||||
else this.probeFrame = requestAnimationFrame(this.probe)
|
||||
}
|
||||
|
||||
private nearBottom(metrics: ViewportMetrics): boolean {
|
||||
return metrics.floor - metrics.top <= FOLLOW_THRESHOLD + 1
|
||||
}
|
||||
|
||||
private commit(
|
||||
landing: ViewportLanding, followingTail: boolean, activeTurn: number | null, initialized = true,
|
||||
): void {
|
||||
@@ -166,6 +164,7 @@ export class ChatReading {
|
||||
}
|
||||
|
||||
private publish(state: ChatReadingState): void {
|
||||
this.follow.setFollowing(state.followingTail)
|
||||
if (state.initialized === this.state.initialized && state.followingTail === this.state.followingTail
|
||||
&& state.activeTurn === this.state.activeTurn) return
|
||||
this.state = state
|
||||
@@ -184,7 +183,7 @@ export class ChatReading {
|
||||
if (this.pending) return
|
||||
const scroll = this.viewport.readScroll()
|
||||
if (scroll === null) return
|
||||
const activeTurn = this.nearBottom(scroll.metrics) ? this.viewport.latestTurn : this.viewport.readVisibleTurn(scroll.metrics)
|
||||
const activeTurn = this.follow.nearBottom(scroll.metrics) ? this.viewport.latestTurn : this.viewport.readVisibleTurn(scroll.metrics)
|
||||
this.publish({ ...this.state, initialized: true, activeTurn })
|
||||
}
|
||||
|
||||
@@ -193,14 +192,14 @@ export class ChatReading {
|
||||
this.cancelPending()
|
||||
const scroll = this.viewport.readScroll()
|
||||
if (scroll === null) return
|
||||
const followingTail = scroll.movedByReader ? this.nearBottom(scroll.metrics) : this.state.followingTail
|
||||
const followingTail = this.follow.sample(scroll.metrics, scroll.movedByReader)
|
||||
let position: ChatScrollPosition | null = null
|
||||
if (!scroll.movedByReader && followingTail) this.followTail()
|
||||
else {
|
||||
position = followingTail ? null : this.viewport.capturePosition()
|
||||
this.viewport.acknowledge(scroll.metrics)
|
||||
if (followingTail || position !== null) this.store.save(position)
|
||||
const activeTurn = this.nearBottom(scroll.metrics) ? this.viewport.latestTurn : this.viewport.readVisibleTurn(scroll.metrics)
|
||||
const activeTurn = this.follow.nearBottom(scroll.metrics) ? this.viewport.latestTurn : this.viewport.readVisibleTurn(scroll.metrics)
|
||||
this.publish({ initialized: true, followingTail, activeTurn })
|
||||
}
|
||||
this.sampled?.({ position, movedByReader: scroll.movedByReader, followingTail })
|
||||
@@ -222,7 +221,8 @@ export function useChatReading(
|
||||
followingTail: store.read() === null,
|
||||
activeTurn: initialTurn,
|
||||
}))
|
||||
const [reading] = useState(() => new ChatReading(viewport, store, state, setState))
|
||||
const follow = useScrollFollow(state.followingTail, FOLLOW_THRESHOLD + 1)
|
||||
const [reading] = useState(() => new ChatReading(viewport, store, state, setState, follow))
|
||||
useLayoutEffect(() => { reading.setStore(store) }, [reading, store])
|
||||
useLayoutEffect(() => () => { reading.dispose() }, [reading])
|
||||
return { reading, state }
|
||||
|
||||
@@ -2,13 +2,8 @@
|
||||
import { useLayoutEffect, useRef, useState, type RefObject } from 'react'
|
||||
import type { ChatScrollPosition } from '../contract/slots.ts'
|
||||
import type { ChatSnapshot } from '../contract/snapshot.ts'
|
||||
|
||||
/** Scroll position, maximum top, and viewport height from one geometry read. */
|
||||
export interface ViewportMetrics {
|
||||
readonly top: number
|
||||
readonly floor: number
|
||||
readonly height: number
|
||||
}
|
||||
import { scrollMetrics, ScrollFollow, type ViewportMetrics } from './use-scroll-follow.ts'
|
||||
export type { ViewportMetrics } from './use-scroll-follow.ts'
|
||||
|
||||
/** Scroll geometry attributed against the last acknowledged position. */
|
||||
export interface ViewportScroll {
|
||||
@@ -150,8 +145,7 @@ export class ChatViewport {
|
||||
private metrics(): ViewportMetrics | null {
|
||||
const scroller = this.elements?.scroller
|
||||
if (scroller === undefined) return null
|
||||
const height = scroller.clientHeight
|
||||
return { top: scroller.scrollTop, height, floor: Math.max(0, scroller.scrollHeight - height) }
|
||||
return scrollMetrics(scroller)
|
||||
}
|
||||
|
||||
private anchor(key: string, identity: 'position' | 'node' = 'position'): HTMLElement | null {
|
||||
@@ -343,6 +337,7 @@ export class ChatViewport {
|
||||
|
||||
/**
|
||||
* Compensate inner scrolling first, then the outer scrollport, within their actual scroll ranges.
|
||||
* An inner write pauses its bound follow controller so the reading anchor takes priority.
|
||||
* @returns the actual landing, or null when no visible retained row remains.
|
||||
*/
|
||||
preserve(): ViewportLanding | null {
|
||||
@@ -366,9 +361,16 @@ export class ChatViewport {
|
||||
}
|
||||
if (group !== null && group.body.contains(row)) {
|
||||
const top = row.getBoundingClientRect().top - group.body.getBoundingClientRect().top
|
||||
const floor = Math.max(0, group.body.scrollHeight - group.body.clientHeight)
|
||||
const target = Math.max(0, Math.min(floor, group.body.scrollTop + top - group.top))
|
||||
if (group.body.scrollTop !== target) group.body.scrollTop = target
|
||||
const metrics = scrollMetrics(group.body)
|
||||
const target = Math.max(0, Math.min(metrics.floor, metrics.top + top - group.top))
|
||||
if (metrics.top !== target) {
|
||||
const follow = ScrollFollow.forElement(group.body)
|
||||
if (follow === undefined) group.body.scrollTop = target
|
||||
else {
|
||||
follow.jump(group.body, metrics, target)
|
||||
follow.setFollowing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
const metrics = this.metrics()
|
||||
if (metrics === null) return null
|
||||
@@ -379,11 +381,19 @@ export class ChatViewport {
|
||||
|
||||
/**
|
||||
* Align the scrollport with its current floor.
|
||||
* @param follow - independent follow intent and scrolling controller.
|
||||
* @returns the actual floor landing, or null while detached.
|
||||
*/
|
||||
scrollToBottom(): ViewportLanding | null {
|
||||
scrollToBottom(follow: ScrollFollow): ViewportLanding | null {
|
||||
const metrics = this.metrics()
|
||||
return metrics === null ? null : this.write(metrics.floor, metrics, this.latestTurn)
|
||||
if (metrics === null || this.elements === null) return null
|
||||
const landing: ViewportLanding = {
|
||||
metrics: follow.toBottom(this.elements.scroller, metrics, 'instant'),
|
||||
position: null,
|
||||
turn: this.latestTurn,
|
||||
}
|
||||
this.observation = { top: landing.metrics.top, landing }
|
||||
return landing
|
||||
}
|
||||
|
||||
private align(row: HTMLElement, offset: number, turn: number | null): ViewportLanding | null {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/** Capped process-group scrolling and fades over the shared follow controller. */
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState, type DOMAttributes, type KeyboardEvent, type RefObject } from 'react'
|
||||
import { scrollMetrics, useScrollFollow } from './use-scroll-follow.ts'
|
||||
|
||||
interface ScrollEdges { readonly canScrollUp: boolean; readonly canScrollDown: boolean }
|
||||
const AT_REST: ScrollEdges = { canScrollUp: false, canScrollDown: false }
|
||||
const SCROLL_KEYS = new Set(['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '])
|
||||
|
||||
/**
|
||||
* Observe one group's body and content without coupling its follow intent to the outer transcript.
|
||||
* Wheel, touchstart, any pointerdown, and unprevented scroll keys interrupt active animations,
|
||||
* including events from editable controls; subsequent position sampling determines follow intent.
|
||||
* @param bodyRef - capped scrolling body.
|
||||
* @param contentRef - uncapped content whose size reports growth.
|
||||
* @param open - local disclosure state.
|
||||
* @param grouped - whether the display mode retains the group's height cap.
|
||||
* @returns edge fades, DOM event bindings, and one-shot positioning for manual opening.
|
||||
*/
|
||||
export function useProcessScroll(
|
||||
bodyRef: RefObject<HTMLDivElement>, contentRef: RefObject<HTMLDivElement>, open: boolean, grouped: boolean,
|
||||
): {
|
||||
edges: ScrollEdges
|
||||
events: Pick<DOMAttributes<HTMLDivElement>, 'onScroll' | 'onWheel' | 'onTouchStart' | 'onPointerDown' | 'onKeyDown'>
|
||||
initialize: (position: 'top' | 'bottom') => void
|
||||
} {
|
||||
const follow = useScrollFollow(false, 1)
|
||||
const initialPosition = useRef<'top' | 'bottom' | null>(null)
|
||||
const [edges, setEdges] = useState<ScrollEdges>(AT_REST)
|
||||
const initialize = useCallback((position: 'top' | 'bottom') => { initialPosition.current = position }, [])
|
||||
const sync = useCallback((cause: 'resize' | 'scroll' | 'scrollend') => {
|
||||
const body = bodyRef.current
|
||||
let next = AT_REST
|
||||
if (body !== null && body.closest('[hidden], [data-group-expanded-mode]') === null) {
|
||||
let metrics = scrollMetrics(body)
|
||||
const initial = cause === 'resize' ? initialPosition.current : null
|
||||
if (initial !== null) {
|
||||
metrics = follow.jump(body, metrics, initial === 'bottom' ? metrics.floor : 0)
|
||||
if (initial === 'top') follow.setFollowing(false)
|
||||
initialPosition.current = null
|
||||
} else {
|
||||
const wasAnimating = follow.animating
|
||||
if (cause === 'scrollend') follow.settle(metrics)
|
||||
else follow.sample(metrics)
|
||||
if (follow.active && (cause === 'resize' || (cause === 'scrollend' && wasAnimating))) {
|
||||
metrics = follow.toBottom(body, metrics, 'smooth')
|
||||
}
|
||||
}
|
||||
next = { canScrollUp: metrics.top > 1, canScrollDown: metrics.top < metrics.floor - 1 }
|
||||
} else follow.reset()
|
||||
setEdges(previous => previous.canScrollUp === next.canScrollUp && previous.canScrollDown === next.canScrollDown
|
||||
? previous : next)
|
||||
}, [bodyRef, follow])
|
||||
const interrupt = useCallback(() => {
|
||||
const body = bodyRef.current
|
||||
if (body !== null && follow.animating) follow.interrupt(body, scrollMetrics(body))
|
||||
}, [bodyRef, follow])
|
||||
const events = useMemo(() => ({
|
||||
onScroll: () => { sync('scroll') },
|
||||
onWheel: interrupt,
|
||||
onTouchStart: interrupt,
|
||||
onPointerDown: interrupt,
|
||||
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!event.defaultPrevented && SCROLL_KEYS.has(event.key)) interrupt()
|
||||
},
|
||||
}), [interrupt, sync])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
interrupt()
|
||||
follow.reset()
|
||||
if (!grouped || !open) initialPosition.current = null
|
||||
}, [follow, grouped, interrupt, open])
|
||||
useLayoutEffect(() => {
|
||||
const body = bodyRef.current
|
||||
if (body === null || !open || typeof ResizeObserver === 'undefined') return
|
||||
const unbind = follow.bind(body)
|
||||
const observer = new ResizeObserver(() => { sync('resize') })
|
||||
const onScrollEnd = (event: Event): void => { if (event.target === body) sync('scrollend') }
|
||||
body.addEventListener('scrollend', onScrollEnd)
|
||||
observer.observe(body)
|
||||
if (contentRef.current !== null) observer.observe(contentRef.current)
|
||||
return () => {
|
||||
unbind()
|
||||
observer.disconnect()
|
||||
body.removeEventListener('scrollend', onScrollEnd)
|
||||
}
|
||||
}, [bodyRef, contentRef, follow, open, sync])
|
||||
return { edges, events, initialize }
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/** Independent bottom-follow intent and native scrolling, without paging or DOM observers. */
|
||||
import { useState } from 'react'
|
||||
|
||||
/** Scroll position, maximum top, and viewport height from one geometry read. */
|
||||
export interface ViewportMetrics {
|
||||
readonly top: number
|
||||
readonly floor: number
|
||||
readonly height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one scrollport without measuring its children.
|
||||
* @param element - scrolling element.
|
||||
* @returns current position and range.
|
||||
*/
|
||||
export function scrollMetrics(element: HTMLElement): ViewportMetrics {
|
||||
const height = element.clientHeight
|
||||
return { top: element.scrollTop, height, floor: Math.max(0, element.scrollHeight - height) }
|
||||
}
|
||||
|
||||
/** One scrollport's follow intent; native animation progress does not count as reader movement. */
|
||||
export class ScrollFollow {
|
||||
private static readonly owners = new WeakMap<HTMLElement, ScrollFollow>()
|
||||
private target: number | null = null
|
||||
private sampledTop: number | undefined
|
||||
|
||||
/**
|
||||
* @param following - initial follow intent.
|
||||
* @param threshold - accepted distance from the floor, in pixels.
|
||||
*/
|
||||
constructor(private following: boolean, private readonly threshold: number) {}
|
||||
|
||||
/**
|
||||
* Find the mounted controller for reading-position compensation.
|
||||
* @param element - scrollport with an optional follow owner.
|
||||
* @returns its controller, when bound.
|
||||
*/
|
||||
static forElement(element: HTMLElement): ScrollFollow | undefined { return this.owners.get(element) }
|
||||
|
||||
/**
|
||||
* Share this controller with reading-position compensation for the same scrollport.
|
||||
* @param element - owned scrollport.
|
||||
* @returns release the association on unmount or close.
|
||||
*/
|
||||
bind(element: HTMLElement): () => void {
|
||||
ScrollFollow.owners.set(element, this)
|
||||
return () => { if (ScrollFollow.owners.get(element) === this) ScrollFollow.owners.delete(element) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose follow intent independently of the current offset.
|
||||
* @returns whether content growth should follow the floor.
|
||||
*/
|
||||
get active(): boolean { return this.following }
|
||||
|
||||
/**
|
||||
* Expose outstanding native motion before accepting reader input.
|
||||
* @returns whether a native follow animation has an outstanding target.
|
||||
*/
|
||||
get animating(): boolean { return this.target !== null }
|
||||
|
||||
/**
|
||||
* Classify bottom arrivals using this scrollport's own tolerance.
|
||||
* @param metrics - current scroll geometry.
|
||||
* @returns whether the position is within the follow threshold.
|
||||
*/
|
||||
nearBottom(metrics: ViewportMetrics): boolean { return metrics.floor - metrics.top <= this.threshold }
|
||||
|
||||
/**
|
||||
* Commit caller-owned follow decisions without moving the scrollport.
|
||||
* @param active - externally committed follow intent.
|
||||
*/
|
||||
setFollowing(active: boolean): void {
|
||||
this.following = active
|
||||
if (!active) this.target = null
|
||||
}
|
||||
|
||||
/** Adopt the next visible layout as a fresh reader position. */
|
||||
reset(): void {
|
||||
this.setFollowing(false)
|
||||
this.sampledTop = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt delivered scrolling while retaining intent during native animation.
|
||||
* @param metrics - current geometry.
|
||||
* @param movedByReader - caller attribution; omitted callers compare the last sampled position.
|
||||
* @returns updated follow intent.
|
||||
*/
|
||||
sample(metrics: ViewportMetrics, movedByReader = this.sampledTop === undefined
|
||||
|| Math.abs(metrics.top - this.sampledTop) > 0.5): boolean {
|
||||
this.sampledTop = metrics.top
|
||||
if (!this.animating && movedByReader) this.following = this.nearBottom(metrics)
|
||||
return this.active
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle native scrolling; an off-target stop releases follow intent.
|
||||
* @param metrics - actual geometry delivered at scrollend.
|
||||
* @returns follow intent after completing or interrupting native motion.
|
||||
*/
|
||||
settle(metrics: ViewportMetrics): boolean {
|
||||
const target = this.target
|
||||
this.target = null
|
||||
return this.sample(metrics, target === null ? undefined
|
||||
: Math.abs(metrics.top - Math.min(target, metrics.floor)) > this.threshold)
|
||||
}
|
||||
|
||||
/**
|
||||
* Position immediately and adopt the resulting follow intent.
|
||||
* @param element - scrolling element.
|
||||
* @param metrics - geometry before positioning.
|
||||
* @param top - requested offset, clamped to the measured range.
|
||||
* @returns geometry after positioning.
|
||||
*/
|
||||
jump(element: HTMLElement, metrics: ViewportMetrics, top: number): ViewportMetrics {
|
||||
const animated = this.animating
|
||||
this.target = null
|
||||
const target = Math.max(0, Math.min(metrics.floor, top))
|
||||
if (animated) element.scrollTo({ top: target, behavior: 'instant' })
|
||||
else if (target !== metrics.top) element.scrollTop = target
|
||||
const landed = { ...metrics, top: element.scrollTop }
|
||||
this.sampledTop = landed.top
|
||||
this.following = this.nearBottom(landed)
|
||||
return landed
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow the measured floor, respecting reduced motion for smooth requests.
|
||||
* An outstanding smooth target finishes before another is issued.
|
||||
* Within-tolerance positioning is immediate while no animation is outstanding.
|
||||
* @param element - scrolling element.
|
||||
* @param metrics - current geometry.
|
||||
* @param behavior - native animation for growth, or immediate positioning.
|
||||
* @returns current geometry; smooth requests retain their starting position until native scroll delivery.
|
||||
*/
|
||||
toBottom(element: HTMLElement, metrics: ViewportMetrics, behavior: 'instant' | 'smooth'): ViewportMetrics {
|
||||
this.following = true
|
||||
if (behavior === 'instant' || metrics.top >= metrics.floor
|
||||
|| (!this.animating && this.nearBottom(metrics))) return this.jump(element, metrics, metrics.floor)
|
||||
if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
return this.jump(element, metrics, metrics.floor)
|
||||
}
|
||||
if (this.target === null) {
|
||||
this.target = metrics.floor
|
||||
element.scrollTo({ top: metrics.floor, behavior: 'smooth' })
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel native motion before a reader gesture; only subsequent actual movement changes follow intent.
|
||||
* @param element - scrolling element.
|
||||
* @param metrics - position at interruption.
|
||||
*/
|
||||
interrupt(element: HTMLElement, metrics: ViewportMetrics): void {
|
||||
if (!this.animating) return
|
||||
this.target = null
|
||||
this.sampledTop = metrics.top
|
||||
element.scrollTo({ top: metrics.top, behavior: 'instant' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retain one independent follow controller without React updates for scroll samples.
|
||||
* @param initial - initial follow intent.
|
||||
* @param threshold - accepted distance from the floor, in pixels.
|
||||
* @returns the stable controller shared by the caller's scroll and resize handlers.
|
||||
*/
|
||||
export function useScrollFollow(initial: boolean, threshold: number): ScrollFollow {
|
||||
const [follow] = useState(() => new ScrollFollow(initial, threshold))
|
||||
return follow
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-chat/src/client/conversation-nodes/README.md
|
||||
README.md: 9dca6245db9383f8632f45b514454ad3ef201f35
|
||||
README.zh.md: b0cc2cfa51306a614da36cb9f21734136864932a
|
||||
README.md: 3a1660677e173461c353da1d40af3e8adae6d216
|
||||
README.zh.md: 024ea0f5da55826651611a7059a647b0c6478e08
|
||||
@@ -153,6 +153,8 @@ English lowercases the initial letter of joined labels after the first. Closing
|
||||
|
||||
Group headers show a category icon, replace it with a down arrow on hover or keyboard focus, and show an up arrow while open. Manually expanded group bodies use 8px row spacing, a `min(400px, 50vh)` height cap, and 24px directional fades. Wheel scrolling can continue into the outer transcript at an edge. Expanded mode removes the group-level cap and uses 16px row spacing only in running Turns.
|
||||
|
||||
An open capped group follows content growth only while its own scroll position is at the bottom. Scrolling away pauses that group's following; returning to the bottom resumes it, independently of outer transcript following. Manually opening an unclosed group starts at the bottom and follows growth; manually opening a closed group starts at the top with following disabled, even when its initial content fits without scrolling. Closing the group in the data or restoring its height cap through a mode change does not reset an already-open reader's position. Browser find retains its own reveal position.
|
||||
|
||||
Individual reasoning starts collapsed, including while streaming. All modes preview the latest paragraph whose first line ends with a newline; an unfinished single line has no preview. Later text in that paragraph does not change the preview. After settlement, the mode table applies. Expanded reasoning uses compact Markdown typography.
|
||||
|
||||
Switching modes retains manually opened groups and inner disclosures. It changes visibility and sizing without recreating the message rows. Expanded does not mean opening every individual disclosure or unfolding the whole Turn.
|
||||
|
||||
@@ -153,6 +153,8 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
|
||||
|
||||
组头显示类别图标,悬停或键盘聚焦时替换为向下箭头,展开时显示向上箭头。手动展开的组正文使用 8px 行间距、`min(400px, 50vh)` 限高及 24px 方向渐隐;到达边缘时滚轮可以继续滚动外层文本记录。完全展开模式仅对运行中轮次取消组级限高,使用 16px 行间距。
|
||||
|
||||
已展开的限高组仅在自己的滚动位置处于底部时跟随内容增长。滚离底部暂停该组跟随,滚回底部后恢复,与外层文本记录的跟随状态独立。手动展开未关闭的组时定位到底部并跟随增长;手动展开已关闭的组时从顶部开始且不启用跟随,即使初始内容无需滚动即可容纳。数据中的组变为已关闭,或通过模式切换恢复限高时,不重置已展开组的阅读位置。浏览器查找沿用自身的展开定位。
|
||||
|
||||
单个推理初始收起,包括流式输出期间。三档都预览最近一个首行以换行结束的段落;未完成的单行没有预览,该段落后续文本不改变预览。结算后按上表展示。展开的推理使用紧凑 Markdown 排版。
|
||||
|
||||
切换模式保留手动展开的组和内部开合状态,只改变显隐与尺寸,不重建消息行。完全展开不表示自动打开每个内部控件,也不表示展开整轮。
|
||||
|
||||
@@ -755,7 +755,11 @@ describe('ChatView', () => {
|
||||
expect(bodies[1]!.hasAttribute('hidden')).toBe(true)
|
||||
})
|
||||
|
||||
it('waits for observed layout before measuring an opened process group', () => {
|
||||
it.each([
|
||||
{ initialHeight: 200, closed: true },
|
||||
{ initialHeight: 600, closed: true },
|
||||
{ initialHeight: 600, closed: false },
|
||||
])('opens and follows a $initialHeight px process group with closed=$closed', ({ initialHeight, closed }) => {
|
||||
const observers = new Set<Observer>()
|
||||
class Observer implements ResizeObserver {
|
||||
readonly targets = new Set<Element>()
|
||||
@@ -769,12 +773,13 @@ describe('ChatView', () => {
|
||||
const h = makeHarness({}, {}, snapshot)
|
||||
const key = 'observed-process' as GroupKey
|
||||
const groups = new ConversationGroupStore<ProcessGroupData>()
|
||||
const group: GroupSnapshot<ProcessGroupData> = {
|
||||
key, members: [{ kind: 'node', key: snapshot.order[0] as NodeKey }],
|
||||
data: { turn: 1, closed, summary: { counts: [], running: undefined, runningDetail: '' } },
|
||||
}
|
||||
groups.prepareAndInstall({
|
||||
entries: [{ kind: 'group', key }],
|
||||
groups: { kind: 'replace', snapshots: [{
|
||||
key, members: [{ kind: 'node', key: snapshot.order[0] as NodeKey }],
|
||||
data: { turn: 1, closed: true, summary: { counts: [], running: undefined, runningDetail: '' } },
|
||||
}] },
|
||||
groups: { kind: 'replace', snapshots: [group] },
|
||||
}, key => snapshot.nodes.get(key))
|
||||
h.setGrouped(groups)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
@@ -782,10 +787,23 @@ describe('ChatView', () => {
|
||||
const body = view.container.querySelector<HTMLElement>('[data-step-process-body]')!
|
||||
let reads = 0
|
||||
let top = 0
|
||||
let height = initialHeight
|
||||
let animatedTop: number | null = null
|
||||
const scrollTo = vi.fn((options: ScrollToOptions) => {
|
||||
if (options.behavior === 'smooth') animatedTop = options.top ?? top
|
||||
else { animatedTop = null; top = options.top ?? top }
|
||||
})
|
||||
const finishScroll = (): void => {
|
||||
top = animatedTop ?? top
|
||||
animatedTop = null
|
||||
fireEvent.scroll(body)
|
||||
fireEvent(body, new Event('scrollend'))
|
||||
}
|
||||
Object.defineProperties(body, {
|
||||
scrollTop: { get: () => { reads++; return top } },
|
||||
scrollTop: { get: () => { reads++; return top }, set: (value: number) => { top = value } },
|
||||
clientHeight: { get: () => { reads++; return 200 } },
|
||||
scrollHeight: { get: () => { reads++; return 600 } },
|
||||
scrollHeight: { get: () => { reads++; return height } },
|
||||
scrollTo: { value: scrollTo },
|
||||
})
|
||||
|
||||
fireEvent.click(header)
|
||||
@@ -793,16 +811,87 @@ describe('ChatView', () => {
|
||||
const observer = [...observers].find(observer => observer.targets.has(body))!
|
||||
expect(observer).toBeDefined()
|
||||
act(() => { observer.callback([], observer) })
|
||||
expect(body.hasAttribute('data-scroll-up')).toBe(false)
|
||||
expect(body.hasAttribute('data-scroll-down')).toBe(true)
|
||||
expect(top).toBe(closed ? 0 : initialHeight - 200)
|
||||
expect(body.hasAttribute('data-scroll-up')).toBe(!closed && initialHeight > 200)
|
||||
expect(body.hasAttribute('data-scroll-down')).toBe(closed && initialHeight > 200)
|
||||
|
||||
height = 800
|
||||
act(() => { observer.callback([], observer) })
|
||||
finishScroll()
|
||||
expect(top).toBe(closed ? 0 : 600)
|
||||
|
||||
top = 400
|
||||
fireEvent.scroll(body)
|
||||
expect(body.hasAttribute('data-scroll-up')).toBe(true)
|
||||
expect(body.hasAttribute('data-scroll-down')).toBe(true)
|
||||
height = 1_000
|
||||
act(() => { observer.callback([], observer) })
|
||||
expect(top).toBe(400)
|
||||
|
||||
top = 800
|
||||
fireEvent.scroll(body)
|
||||
height = 1_200
|
||||
act(() => { observer.callback([], observer) })
|
||||
finishScroll()
|
||||
expect(top).toBe(1_000)
|
||||
expect(body.hasAttribute('data-scroll-down')).toBe(false)
|
||||
|
||||
height = 1_400
|
||||
act(() => { observer.callback([], observer) })
|
||||
expect(animatedTop).toBe(1_200)
|
||||
const beforeNestedEnd = reads
|
||||
fireEvent(body.firstElementChild!, new Event('scrollend', { bubbles: true }))
|
||||
expect(reads).toBe(beforeNestedEnd)
|
||||
// Find/focus can reposition the body without firing its input handlers.
|
||||
top = 900
|
||||
animatedTop = null
|
||||
const requests = scrollTo.mock.calls.length
|
||||
finishScroll()
|
||||
expect(animatedTop).toBeNull()
|
||||
expect(scrollTo).toHaveBeenCalledTimes(requests)
|
||||
height = 1_600
|
||||
act(() => { observer.callback([], observer) })
|
||||
expect(top).toBe(900)
|
||||
expect(scrollTo).toHaveBeenCalledTimes(requests)
|
||||
const measured = reads
|
||||
fireEvent.click(header)
|
||||
expect(reads).toBe(measured)
|
||||
expect(observers.has(observer)).toBe(false)
|
||||
|
||||
height = 1_400
|
||||
fireEvent.click(header)
|
||||
expect(reads).toBe(measured)
|
||||
const reopened = [...observers].find(observer => observer.targets.has(body))!
|
||||
act(() => { reopened.callback([], reopened) })
|
||||
expect(top).toBe(closed ? 0 : 1_200)
|
||||
expect(body.hasAttribute('data-scroll-down')).toBe(closed)
|
||||
|
||||
top = 400
|
||||
fireEvent.scroll(body)
|
||||
act(() => {
|
||||
groups.prepareAndInstall({ groups: { kind: 'apply', removes: [], upserts: [{
|
||||
...group, data: { ...group.data, closed: true },
|
||||
}] } }, key => snapshot.nodes.get(key))
|
||||
groups.publish()
|
||||
})
|
||||
expect(top).toBe(400)
|
||||
expect(observers.has(reopened)).toBe(true)
|
||||
height = 1_600
|
||||
act(() => { reopened.callback([], reopened) })
|
||||
expect(top).toBe(400)
|
||||
fireEvent.click(header)
|
||||
fireEvent.click(header)
|
||||
const historical = [...observers].find(observer => observer.targets.has(body))!
|
||||
act(() => { historical.callback([], historical) })
|
||||
expect(top).toBe(0)
|
||||
|
||||
top = 300
|
||||
fireEvent.scroll(body)
|
||||
fireEvent.click(header)
|
||||
fireEvent(body, new Event('beforematch'))
|
||||
const found = [...observers].find(observer => observer.targets.has(body))!
|
||||
act(() => { found.callback([], found) })
|
||||
expect(top).toBe(300)
|
||||
})
|
||||
|
||||
it('does not recalculate settled group titles when work-details mode changes', () => {
|
||||
@@ -972,7 +1061,7 @@ describe('ChatView', () => {
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const second = await view.findByRole('button', { name: '跳转到第 2 轮' })
|
||||
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
const metrics = installScrollMetrics(scroller, 1_000, 300)
|
||||
metrics.setLayout(1_000, 700)
|
||||
vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ top: 0, bottom: 300 } as DOMRect)
|
||||
@@ -1035,7 +1124,7 @@ describe('ChatView', () => {
|
||||
] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const first = await view.findByRole('button', { name: '跳转到第 1 轮' })
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
installScrollMetrics(scroller, 1_500, 300)
|
||||
const row = view.container.querySelector('[data-chat-anchor-key="fixture:user:1"]') as HTMLElement
|
||||
vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 800, 300))
|
||||
@@ -1080,7 +1169,7 @@ describe('ChatView', () => {
|
||||
expect(view.getByRole('button', { name: '回到底部' })).toBeTruthy()
|
||||
// ...so a non-reader scroll delivery at the floor (the first prepend's
|
||||
// compensation fires one) no longer snaps to the tail and cancel the jump.
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLElement
|
||||
fireEvent.scroll(scroller)
|
||||
expect(first.getAttribute('aria-busy')).toBe('true')
|
||||
await act(async () => { releaseJump?.() })
|
||||
@@ -1149,7 +1238,7 @@ describe('ChatView', () => {
|
||||
let releaseJump: (() => void) | undefined
|
||||
h.loadThrough.mockImplementation(() => new Promise<void>((resolve) => { releaseJump = resolve }))
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
installScrollMetrics(scroller, 1_000, 300)
|
||||
scroller.scrollTop = 700
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
@@ -1197,7 +1286,7 @@ describe('ChatView', () => {
|
||||
{ hasMore: true },
|
||||
)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
const first = view.container.querySelector('[data-chat-flow-key="fixture:user:9"]') as HTMLDivElement
|
||||
const next = view.container.querySelector('[data-chat-flow-key="fixture:user:10"]') as HTMLDivElement
|
||||
let firstTop = 100
|
||||
@@ -1256,7 +1345,7 @@ describe('ChatView', () => {
|
||||
const pinned = anchor === 'group' ? group : old
|
||||
const expectedTop = anchor === 'group' ? 30 : 60
|
||||
const expectedScroll = anchor === 'group' ? 200 : 300
|
||||
const scroller = view.container.querySelector<HTMLElement>('[class*="scroll"]')!
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement!
|
||||
installScrollMetrics(scroller, 1500, 400)
|
||||
let paged = false
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
@@ -1326,7 +1415,7 @@ describe('ChatView', () => {
|
||||
h.setGrouped(groups)
|
||||
h.setTranscriptView(mode)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector<HTMLElement>('[class*="scroll"]')!
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement!
|
||||
const column = scroller.querySelector<HTMLElement>('[data-chat-flow]')!
|
||||
const answer = view.container.querySelector<HTMLElement>('[data-chat-node-key="fixture:assistant:6"]')!
|
||||
const first = hasSteering ? view.container.querySelector<HTMLElement>('[data-chat-node-key="fixture:steering:5"]')! : answer
|
||||
@@ -1380,7 +1469,7 @@ describe('ChatView', () => {
|
||||
try {
|
||||
const h = makeHarness({ nodes: [user(1, 'visible row')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
const anchor = view.container.querySelector('[data-chat-anchor-key="fixture:user:1"]') as HTMLElement
|
||||
installScrollMetrics(scroller, 4_000, 2_000)
|
||||
vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({
|
||||
@@ -1411,7 +1500,7 @@ describe('ChatView', () => {
|
||||
{ hasMore: true },
|
||||
)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
const rows = [...view.container.querySelectorAll<HTMLElement>('[data-chat-flow-key]')]
|
||||
let prepended = false
|
||||
let rowRectCalls = 0
|
||||
@@ -1596,6 +1685,14 @@ describe('ChatView', () => {
|
||||
)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByText('即发即显').closest('[data-submission-echo]')).not.toBeNull()
|
||||
const echo = view.getByText('即发即显').closest('[data-submission-echo]')
|
||||
act(() => { h.setSession({ testInbox: { 'next-step': [], 'next-turn': [{
|
||||
id: 'idle-occurrence' as never, role: 'user', source: { kind: 'user', rpcId: 'req-1' as never },
|
||||
content: [{ type: 'text', text: '即发即显' }],
|
||||
}] } }) })
|
||||
expect(view.getByText('即发即显').closest('[data-submission-echo]')).toBe(echo)
|
||||
act(() => { h.setSession({ testInbox: { 'next-step': [], 'next-turn': [] } }) })
|
||||
expect(view.getByText('即发即显').closest('[data-submission-echo]')).toBe(echo)
|
||||
|
||||
// The durable node arrives while the echo is STILL in the session
|
||||
// snapshot: the render-time rpcId dedupe keeps exactly one bubble.
|
||||
@@ -1619,10 +1716,99 @@ describe('ChatView', () => {
|
||||
expect(view.getAllByText('即发即显')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each(['compact', 'detailed', 'expanded'] as const)(
|
||||
'keeps the opening echo above a new Turn title and steering below it (%s)', (mode) => {
|
||||
const opening = {
|
||||
requestId: 'opening' as never, placement: 'transcript' as const,
|
||||
time: 5_000, text: 'opening input', attachments: [],
|
||||
}
|
||||
const steer = {
|
||||
requestId: 'steer' as never, placement: 'steering' as const,
|
||||
time: 6_000, text: 'later steering', attachments: [],
|
||||
}
|
||||
const h = makeHarness({}, { pendingSubmissions: [opening, steer] })
|
||||
h.setTranscriptView(mode)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const echo = view.getByText(opening.text).closest('[data-submission-echo]')!
|
||||
const steeringEcho = view.getByText(steer.text).closest('[data-submission-echo]')!
|
||||
act(() => { h.setChat({ turnTimings: new Map([[1, { startTime: 1_000 }]]) }) })
|
||||
const title = view.container.querySelector('[data-chat-flow-kind="turn-process"]')!
|
||||
expect(title).not.toBeNull()
|
||||
expect(view.getByText(opening.text).closest('[data-submission-echo]')).toBe(echo)
|
||||
expect(echo.compareDocumentPosition(title) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(title.compareDocumentPosition(steeringEcho) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
const admitted: UserMessageNode & { turn: number } = {
|
||||
...user(2, opening.text), turn: 1, source: { kind: 'user', rpcId: opening.requestId },
|
||||
}
|
||||
act(() => { h.setChat({ nodes: [admitted] }) })
|
||||
expect(view.getAllByText(opening.text)).toHaveLength(1)
|
||||
expect(view.getByText(steer.text).closest('[data-submission-echo]')).toBe(steeringEcho)
|
||||
expect(view.getByText(opening.text).compareDocumentPosition(title) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
},
|
||||
)
|
||||
|
||||
it('hands off an opening echo when the Turn and input arrive in one render', () => {
|
||||
const h = makeHarness({}, { pendingSubmissions: [{
|
||||
requestId: 'same-frame' as never, placement: 'transcript', time: 1_000, text: 'same frame', attachments: [],
|
||||
}] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const admitted: UserMessageNode & { turn: number } = {
|
||||
...user(2, 'same frame'), turn: 1, source: { kind: 'user', rpcId: 'same-frame' },
|
||||
}
|
||||
act(() => { h.setChat({ nodes: [admitted], turnTimings: new Map([[1, { startTime: 1_000 }]]) }) })
|
||||
expect(view.getAllByText('same frame')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[data-submission-echo]')).toBeNull()
|
||||
expect(renderedFlowKinds(view.container)).toEqual(['user', 'turn-process'])
|
||||
})
|
||||
|
||||
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 => ({
|
||||
requestId: text as never, placement: 'transcript' as const,
|
||||
time: 1_000, text: `input ${text}`, attachments: [],
|
||||
}))
|
||||
const h = makeHarness({}, { pendingSubmissions: pending })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const flow = () => [...view.container.querySelectorAll<HTMLElement>(
|
||||
'[data-submission-echo], [data-chat-flow-kind="user"], [data-chat-flow-kind="turn-process"]',
|
||||
)].map(element => element.matches('[data-chat-flow-kind="turn-process"]') ? 'title'
|
||||
: pending.find(input => element.textContent?.includes(input.text))!.requestId
|
||||
+ (element.hasAttribute('data-submission-echo') ? '*' : ''))
|
||||
expect(flow()).toEqual(['A*', 'B*', 'C*'])
|
||||
const inbox: InboxState = { 'next-step': [], 'next-turn': hostOrder.split('').map(id => ({
|
||||
id: id as never, role: 'user', source: { kind: 'user', rpcId: id as never },
|
||||
content: [{ type: 'text', text: `input ${id}` }],
|
||||
})) }
|
||||
act(() => { h.setSession({ testInbox: inbox }) })
|
||||
act(() => { h.setChat({ turnTimings: new Map([[1, { startTime: 1_000 }]]) }) })
|
||||
expect(flow()).toEqual(['A*', 'title', 'B*', 'C*'])
|
||||
const admitted: UserMessageNode & { turn: number } = {
|
||||
...user(2, `input ${hostOrder[0]}`), turn: 1,
|
||||
source: { kind: 'user', rpcId: hostOrder[0] },
|
||||
}
|
||||
act(() => { h.setChat({ nodes: [admitted] }) })
|
||||
expect(flow()).toEqual([hostOrder[0], 'title', ...pending
|
||||
.filter(input => input.requestId !== hostOrder[0]).map(input => `${input.requestId}*`)])
|
||||
act(() => { h.setSession({ pendingSubmissions: pending.filter(input => input.requestId !== hostOrder[0]) }) })
|
||||
expect(view.getAllByText(`input ${hostOrder[0]}`)).toHaveLength(1)
|
||||
},
|
||||
)
|
||||
|
||||
it('leaves an opening echo below an empty historical Turn title', () => {
|
||||
const h = makeHarness({ turnTimings: new Map([[1, { startTime: 1_000 }]]), turnEnds: new Map([[1, 2]]) }, {
|
||||
pendingSubmissions: [{ requestId: 'opening' as never, placement: 'transcript',
|
||||
time: 3_000, text: 'next input', attachments: [] }],
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const title = view.container.querySelector('[data-chat-flow-kind="turn-process"]')!
|
||||
const echo = view.getByText('next input').closest('[data-submission-echo]')!
|
||||
expect(title.compareDocumentPosition(echo) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not pull the reader back to the tail when a local steer becomes Host-pending', () => {
|
||||
const h = makeHarness({ nodes: [assistant(1, 'working')] }, { running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector<HTMLElement>('[class*="scroll"]')!
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement!
|
||||
installScrollMetrics(scroller, 1_000, 300)
|
||||
act(() => { h.setSession({ pendingSubmissions: [{
|
||||
requestId: 'req-steer' as never, placement: 'steering', time: 5_000, text: 'continue here', attachments: [],
|
||||
@@ -1639,6 +1825,39 @@ describe('ChatView', () => {
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
})
|
||||
|
||||
it.each(['compact', 'detailed', 'expanded'] as const)(
|
||||
'hides an admitted local steer while its Inbox claim projection is delayed (%s)', (mode) => {
|
||||
const pending: InboxState['next-step'] = ['first', 'second'].map(text => ({
|
||||
id: text as never, role: 'user', source: { kind: 'user', rpcId: text as never },
|
||||
content: [{ type: 'text', text: `steer ${text}` }],
|
||||
}))
|
||||
const submissions: SessionSnapshot['pendingSubmissions'] = ['first', 'second'].map(text => ({
|
||||
requestId: text as never, placement: 'steering', time: 5_000,
|
||||
text: `steer ${text}`, attachments: [],
|
||||
}))
|
||||
const h = makeHarness({ nodes: [assistant(1, 'working')] }, {
|
||||
running: true, pendingSubmissions: submissions,
|
||||
testInbox: { 'next-turn': [], 'next-step': pending },
|
||||
})
|
||||
h.setTranscriptView(mode)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const second = view.getByText('steer second').closest('[data-submission-echo]')
|
||||
expect(view.getAllByText('steer first')).toHaveLength(1)
|
||||
|
||||
act(() => { h.setChat({ nodes: [assistant(1, 'working'), {
|
||||
...steering(2, 'steer first', 1), source: pending[0]!.source,
|
||||
}] }) })
|
||||
expect(view.getAllByText('steer first')).toHaveLength(1)
|
||||
expect(view.getByText('steer first').closest('[data-pending-steering]')).toBeNull()
|
||||
expect(view.getByText('steer second').closest('[data-submission-echo]')).toBe(second)
|
||||
|
||||
act(() => { h.setSession({ testInbox: { 'next-turn': [], 'next-step': [pending[1]!] } }) })
|
||||
act(() => { h.setSession({ pendingSubmissions: [submissions[1]!] }) })
|
||||
expect(view.getAllByText('steer first')).toHaveLength(1)
|
||||
expect(view.getByText('steer second').closest('[data-submission-echo]')).toBe(second)
|
||||
},
|
||||
)
|
||||
|
||||
it('retains the same local steer bubble through Host acceptance and claim until its durable node arrives', () => {
|
||||
const h = makeHarness(
|
||||
{ nodes: [assistant(1, 'working')] },
|
||||
@@ -2307,7 +2526,7 @@ describe('ChatView', () => {
|
||||
turnTimings: new Map([[1, { startTime: 0 }]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
const firstRow = view.getByText('first answer').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
|
||||
@@ -2331,7 +2550,7 @@ describe('ChatView', () => {
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(turnProcessControl(view.container)).toBeNull()
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
const processRow = view.container.querySelector<HTMLElement>('[data-chat-node-key="fixture:assistant:2"]')!
|
||||
@@ -3199,7 +3418,7 @@ describe('ChatView', () => {
|
||||
{ hasMore: true },
|
||||
)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
// jsdom has no layout: fake the metrics the anchor math reads.
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true })
|
||||
@@ -3229,7 +3448,7 @@ describe('ChatView', () => {
|
||||
it('back-to-bottom cancels an in-flight paging anchor', () => {
|
||||
const h = makeHarness({ nodes: [user(9, 'late')] }, { hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
readerScroll(scroller, 50)
|
||||
@@ -3244,7 +3463,7 @@ describe('ChatView', () => {
|
||||
it('scrolling away disables follow and shows the back-to-bottom button; clicking returns', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
readerScroll(scroller, 100) // far from bottom
|
||||
@@ -3266,7 +3485,7 @@ describe('ChatView', () => {
|
||||
it('keeps following when a stream-finalization shrink clamp delivers its scroll', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
const metrics = installScrollMetrics(scroller, 1_000, 300)
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
@@ -3289,7 +3508,7 @@ describe('ChatView', () => {
|
||||
it('keeps following when a shrink clamp regrows before scrollend', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
const metrics = installScrollMetrics(scroller, 1_000, 300)
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
@@ -3319,7 +3538,7 @@ describe('ChatView', () => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
const metrics = installScrollMetrics(scroller, 9_931, 300)
|
||||
expect(notify).toBeDefined()
|
||||
scroller.scrollTop = 9_631
|
||||
@@ -3357,7 +3576,7 @@ describe('ChatView', () => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
const metrics = installScrollMetrics(scroller, 1_000, 300)
|
||||
expect(notify).toBeDefined()
|
||||
scroller.scrollTop = 700
|
||||
@@ -3380,7 +3599,7 @@ describe('ChatView', () => {
|
||||
it('keeps following when reader input reaches the floor before growth and scrollend', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
const metrics = installScrollMetrics(scroller, 1_000, 300)
|
||||
readerScroll(scroller, 100)
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
@@ -3400,7 +3619,7 @@ describe('ChatView', () => {
|
||||
const nodes = [user(1, 'q'), assistant(2, 'a')]
|
||||
const h = makeHarness({ nodes })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
const metrics = installScrollMetrics(scroller, 1_000, 300)
|
||||
readerScroll(scroller, 700)
|
||||
scroller.scrollTop = 650
|
||||
@@ -3431,7 +3650,7 @@ describe('ChatView', () => {
|
||||
it('clears an away sample when a back-to-bottom delivery restores pinned ownership', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
const metrics = installScrollMetrics(scroller, 1_000, 300)
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
@@ -3453,7 +3672,7 @@ describe('ChatView', () => {
|
||||
try {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
installScrollMetrics(scroller, 1_000, 300)
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
@@ -3496,7 +3715,7 @@ describe('ChatView', () => {
|
||||
it('uses the last delivered top when compositor scrolling precedes scroll delivery', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
installScrollMetrics(scroller, 1_000, 300)
|
||||
scroller.scrollTop = 700
|
||||
fireEvent.scroll(scroller)
|
||||
@@ -3524,7 +3743,7 @@ describe('ChatView', () => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
scroller.scrollTop = 700
|
||||
@@ -3578,7 +3797,7 @@ describe('ChatView', () => {
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
await view.findByRole('button', { name: '跳转到第 2 轮' })
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
const metrics = installScrollMetrics(scroller, 1_000, 300)
|
||||
scroller.scrollTop = 700
|
||||
act(() => {
|
||||
@@ -3604,7 +3823,7 @@ describe('ChatView', () => {
|
||||
it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
// Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { ChatViewport } from '../src/client/chat/use-chat-viewport.ts'
|
||||
import { ScrollFollow, scrollMetrics } from '../src/client/chat/use-scroll-follow.ts'
|
||||
|
||||
function fixture() {
|
||||
const column = document.createElement('div')
|
||||
@@ -430,6 +431,32 @@ it.each([
|
||||
expect(h.row.getBoundingClientRect().top).toBe(top)
|
||||
})
|
||||
|
||||
it('gives paging compensation priority over an active inner follow animation', () => {
|
||||
const h = nestedFixture(600, 400)
|
||||
const follow = new ScrollFollow(true, 1)
|
||||
onTestFinished(follow.bind(h.body))
|
||||
const scrollTo = vi.fn((options?: ScrollToOptions | number, y?: number) => {
|
||||
if (typeof options === 'number') h.body.scrollTop = y ?? 0
|
||||
else if (options?.behavior === 'instant') h.body.scrollTop = options.top ?? 0
|
||||
})
|
||||
h.body.scrollTo = scrollTo
|
||||
h.body.scrollTop = 100
|
||||
const top = h.begin()
|
||||
follow.toBottom(h.body, scrollMetrics(h.body), 'smooth')
|
||||
expect(follow.animating).toBe(true)
|
||||
h.prepend(200)
|
||||
h.viewport.preserve()
|
||||
expect(h.row.getBoundingClientRect().top).toBe(top)
|
||||
expect(scrollTo).toHaveBeenLastCalledWith({ top: 300, behavior: 'instant' })
|
||||
expect(follow.animating).toBe(false)
|
||||
expect(follow.active).toBe(false)
|
||||
follow.sample(scrollMetrics(h.body))
|
||||
follow.settle(scrollMetrics(h.body))
|
||||
expect(follow.active).toBe(false)
|
||||
h.body.scrollTop = scrollMetrics(h.body).floor
|
||||
expect(follow.sample(scrollMetrics(h.body))).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['wheel', 'touchstart', 'pointerdown', 'keydown', 'beforematch'])(
|
||||
'retains the same paging anchor through later content resizes until %s', (intent) => {
|
||||
const h = nestedFixture(200, 400)
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ScrollFollow, scrollMetrics } from '../src/client/chat/use-scroll-follow.ts'
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
function scrollport() {
|
||||
const element = document.createElement('div')
|
||||
let height = 600
|
||||
const scrollTo = vi.fn((options: ScrollToOptions) => {
|
||||
if (options.behavior === 'instant') element.scrollTop = options.top ?? element.scrollTop
|
||||
})
|
||||
Object.defineProperties(element, {
|
||||
clientHeight: { value: 200 },
|
||||
scrollHeight: { get: () => height },
|
||||
scrollTo: { value: scrollTo },
|
||||
})
|
||||
return { element, scrollTo, metrics: () => scrollMetrics(element), grow: (value: number) => { height = value } }
|
||||
}
|
||||
|
||||
describe('ScrollFollow', () => {
|
||||
it('does not await native events for a fractional floor that cannot move', () => {
|
||||
const world = scrollport()
|
||||
let top = 399.6
|
||||
Object.defineProperty(world.element, 'scrollTop', {
|
||||
get: () => top, set: (value: number) => { top = Math.min(value, world.metrics().floor - 0.4) },
|
||||
})
|
||||
const follow = new ScrollFollow(true, 1)
|
||||
follow.toBottom(world.element, world.metrics(), 'smooth')
|
||||
expect(world.scrollTo).not.toHaveBeenCalled()
|
||||
expect(follow.animating).toBe(false)
|
||||
expect(follow.active).toBe(true)
|
||||
world.grow(800)
|
||||
follow.toBottom(world.element, world.metrics(), 'smooth')
|
||||
expect(world.scrollTo).toHaveBeenCalledExactlyOnceWith({ top: 600, behavior: 'smooth' })
|
||||
top = 599.6
|
||||
follow.sample(world.metrics())
|
||||
follow.toBottom(world.element, world.metrics(), 'smooth')
|
||||
expect(follow.animating).toBe(true)
|
||||
expect(world.scrollTo).toHaveBeenCalledTimes(1)
|
||||
follow.settle(world.metrics())
|
||||
follow.toBottom(world.element, world.metrics(), 'smooth')
|
||||
expect(follow.animating).toBe(false)
|
||||
expect(world.scrollTo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('releases only its own scrollport binding', () => {
|
||||
const { element } = scrollport()
|
||||
const first = new ScrollFollow(false, 1)
|
||||
const second = new ScrollFollow(false, 1)
|
||||
expect(ScrollFollow.forElement(element)).toBeUndefined()
|
||||
const releaseFirst = first.bind(element)
|
||||
const releaseSecond = second.bind(element)
|
||||
releaseFirst()
|
||||
expect(ScrollFollow.forElement(element)).toBe(second)
|
||||
releaseSecond()
|
||||
expect(ScrollFollow.forElement(element)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps independent thresholds and adopts only attributed reader movement', () => {
|
||||
const outer = new ScrollFollow(true, 25)
|
||||
const inner = new ScrollFollow(true, 1)
|
||||
const metrics = { top: 380, floor: 400, height: 200 }
|
||||
expect(outer.sample(metrics, true)).toBe(true)
|
||||
expect(inner.sample(metrics, true)).toBe(false)
|
||||
expect(inner.sample({ ...metrics, top: 400 }, false)).toBe(false)
|
||||
expect(inner.sample({ ...metrics, top: 400 }, true)).toBe(true)
|
||||
inner.reset()
|
||||
expect(inner.active).toBe(false)
|
||||
expect(inner.sample({ ...metrics, top: 400 })).toBe(true)
|
||||
expect(outer.active).toBe(true)
|
||||
})
|
||||
|
||||
it('finishes native motion before following the latest grown floor', () => {
|
||||
const world = scrollport()
|
||||
const follow = new ScrollFollow(false, 1)
|
||||
expect(follow.toBottom(world.element, world.metrics(), 'smooth').top).toBe(0)
|
||||
expect(world.scrollTo).toHaveBeenLastCalledWith({ top: 400, behavior: 'smooth' })
|
||||
world.element.scrollTop = 100
|
||||
expect(follow.sample(world.metrics())).toBe(true)
|
||||
follow.toBottom(world.element, world.metrics(), 'smooth')
|
||||
expect(world.scrollTo).toHaveBeenCalledTimes(1)
|
||||
world.grow(800)
|
||||
follow.toBottom(world.element, world.metrics(), 'smooth')
|
||||
world.grow(1_000)
|
||||
follow.toBottom(world.element, world.metrics(), 'smooth')
|
||||
expect(world.scrollTo).toHaveBeenCalledTimes(1)
|
||||
world.element.scrollTop = 400
|
||||
expect(follow.sample(world.metrics())).toBe(true)
|
||||
expect(follow.animating).toBe(true)
|
||||
expect(follow.settle(world.metrics())).toBe(true)
|
||||
expect(follow.animating).toBe(false)
|
||||
follow.toBottom(world.element, world.metrics(), 'smooth')
|
||||
expect(world.scrollTo).toHaveBeenCalledTimes(2)
|
||||
expect(world.scrollTo).toHaveBeenLastCalledWith({ top: 800, behavior: 'smooth' })
|
||||
world.element.scrollTop = 800
|
||||
expect(follow.sample(world.metrics())).toBe(true)
|
||||
expect(follow.settle(world.metrics())).toBe(true)
|
||||
expect(follow.animating).toBe(false)
|
||||
world.element.scrollTop = 300
|
||||
expect(follow.sample(world.metrics())).toBe(false)
|
||||
})
|
||||
|
||||
it('finishes native motion when shrinking content clamps its target', () => {
|
||||
const world = scrollport()
|
||||
const follow = new ScrollFollow(true, 1)
|
||||
follow.toBottom(world.element, world.metrics(), 'smooth')
|
||||
world.grow(300)
|
||||
world.element.scrollTop = 100
|
||||
expect(follow.sample(world.metrics())).toBe(true)
|
||||
expect(follow.settle(world.metrics())).toBe(true)
|
||||
expect(follow.animating).toBe(false)
|
||||
})
|
||||
|
||||
it.each([150, 600])('releases following after a native stop at %i instead of its target', (top) => {
|
||||
const world = scrollport()
|
||||
const follow = new ScrollFollow(true, 1)
|
||||
follow.toBottom(world.element, world.metrics(), 'smooth')
|
||||
world.grow(1_000)
|
||||
world.element.scrollTop = top
|
||||
expect(follow.sample(world.metrics())).toBe(true)
|
||||
expect(follow.settle(world.metrics())).toBe(false)
|
||||
expect(follow.animating).toBe(false)
|
||||
world.grow(1_200)
|
||||
expect(follow.sample(world.metrics())).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels native motion before input without treating the cancellation itself as scrolling away', () => {
|
||||
const world = scrollport()
|
||||
const follow = new ScrollFollow(true, 1)
|
||||
follow.interrupt(world.element, world.metrics())
|
||||
expect(world.scrollTo).not.toHaveBeenCalled()
|
||||
follow.toBottom(world.element, world.metrics(), 'smooth')
|
||||
world.element.scrollTop = 150
|
||||
follow.sample(world.metrics())
|
||||
follow.interrupt(world.element, world.metrics())
|
||||
expect(world.scrollTo).toHaveBeenLastCalledWith({ top: 150, behavior: 'instant' })
|
||||
expect(follow.animating).toBe(false)
|
||||
expect(follow.sample(world.metrics())).toBe(true)
|
||||
expect(follow.settle(world.metrics())).toBe(true)
|
||||
world.element.scrollTop = 100
|
||||
expect(follow.sample(world.metrics())).toBe(false)
|
||||
world.element.scrollTop = 400
|
||||
expect(follow.sample(world.metrics())).toBe(true)
|
||||
})
|
||||
|
||||
it('positions opening and external landings immediately within the current range', () => {
|
||||
const world = scrollport()
|
||||
const follow = new ScrollFollow(false, 1)
|
||||
expect(follow.toBottom(world.element, world.metrics(), 'instant').top).toBe(400)
|
||||
expect(follow.toBottom(world.element, world.metrics(), 'smooth').top).toBe(400)
|
||||
expect(world.scrollTo).not.toHaveBeenCalled()
|
||||
expect(follow.jump(world.element, world.metrics(), -100).top).toBe(0)
|
||||
expect(follow.active).toBe(false)
|
||||
expect(follow.jump(world.element, world.metrics(), 1_000).top).toBe(400)
|
||||
expect(follow.active).toBe(true)
|
||||
})
|
||||
|
||||
it('uses immediate following under reduced motion', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true })))
|
||||
const world = scrollport()
|
||||
const follow = new ScrollFollow(false, 1)
|
||||
expect(follow.toBottom(world.element, world.metrics(), 'smooth').top).toBe(400)
|
||||
expect(follow.animating).toBe(false)
|
||||
expect(world.scrollTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops a native animation when an explicit jump retains the current offset', () => {
|
||||
const world = scrollport()
|
||||
const follow = new ScrollFollow(false, 1)
|
||||
follow.toBottom(world.element, world.metrics(), 'smooth')
|
||||
follow.jump(world.element, world.metrics(), 0)
|
||||
expect(world.scrollTo).toHaveBeenLastCalledWith({ top: 0, behavior: 'instant' })
|
||||
expect(follow.animating).toBe(false)
|
||||
expect(follow.active).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 48df40483632a7c63ec59d389f920d4dd663155b
|
||||
README.zh.md: f5c34657264f97d8f0178859dcba5e3fc981f17d
|
||||
README.md: 4823aa0a1ba5975c4efc8b6bdddd7ca8c57c068f
|
||||
README.zh.md: 5ef8a1b6621bf8bab7e98cb18722a9aedda29101
|
||||
@@ -68,9 +68,9 @@ Active transcripts in the main occurrence expose content-width drag handles in t
|
||||
|
||||
The width-handle indicator follows the pointer only during a captured drag; ordinary hover leaves its position unchanged.
|
||||
|
||||
The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in a textarea that grows with its content, so line breaks survive re-editing; Enter saves, Shift+Enter breaks the line, and Escape cancels. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
|
||||
The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` from the Session `inbox` projection, including cold recovered messages, and excludes only requestIds still owned by local transcript submissions. Other queued rows retain their normal display and actions. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in a textarea that grows with its content, so line breaks survive re-editing; Enter saves, Shift+Enter breaks the line, and Escape cancels. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
|
||||
|
||||
Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id.
|
||||
Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id.
|
||||
|
||||
Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text.
|
||||
|
||||
|
||||
@@ -68,9 +68,9 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
|
||||
|
||||
宽度拖拽条的指示线只在已捕获指针的拖拽期间跟随指针,普通悬停不改变其位置。
|
||||
|
||||
常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在可随内容增高的 textarea 中展示字面发送文本,因此重新编辑不会丢失换行;Enter 保存,Shift+Enter 换行,Escape 取消。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
|
||||
常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息,仅排除仍由本地 transcript 提交承接的 requestId。其他排队行保留正常展示和操作。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在可随内容增高的 textarea 中展示字面发送文本,因此重新编辑不会丢失换行;Enter 保存,Shift+Enter 换行,Escape 取消。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
|
||||
|
||||
默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。
|
||||
默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。
|
||||
|
||||
排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。
|
||||
|
||||
|
||||
@@ -150,13 +150,19 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock
|
||||
|
||||
/**
|
||||
* Queue strip: one item renders directly; multiple items default to a
|
||||
* collapsible count header; an empty queue renders nothing. Local submissions
|
||||
* collapsible count header; an empty queue renders nothing. Local queued submissions
|
||||
* show sending status and disabled actions until their Host queue rows arrive.
|
||||
*/
|
||||
export function QueueDock({ useSession, useProjection, updateQueue, notify, loadImage, t }: QueueDockProps) {
|
||||
const inbox = useProjection('inbox') as unknown as InboxState | undefined
|
||||
const queue = inbox?.['next-turn'] ?? EMPTY_QUEUE
|
||||
const pendingSubmissions = useSession(s => s.pendingSubmissions)
|
||||
const queue = useMemo(() => {
|
||||
const rows = inbox?.['next-turn'] ?? EMPTY_QUEUE
|
||||
const inChat = new Set(pendingSubmissions.filter(item => item.placement === 'transcript').map(item => item.requestId))
|
||||
return inChat.size === 0 ? rows : rows.filter(({ source }) => (
|
||||
source.kind !== 'user' || !('rpcId' in source) || !inChat.has(source.rpcId)
|
||||
))
|
||||
}, [inbox, pendingSubmissions])
|
||||
const pendingQueue = useMemo(() => {
|
||||
const admitted = new Set(queue.flatMap(({ source }) => (
|
||||
source.kind === 'user' && 'rpcId' in source ? [source.rpcId] : []
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
/** jsdom omits resize observation and font loading; layout tests drive their own measurements. */
|
||||
/** jsdom font-loading events; layout tests drive their own measurements. */
|
||||
import { beforeEach, afterEach, vi } from 'vitest'
|
||||
|
||||
const fonts = Object.getOwnPropertyDescriptor(document, 'fonts')
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
})
|
||||
Object.defineProperty(document, 'fonts', { configurable: true, value: new EventTarget() })
|
||||
})
|
||||
afterEach(() => {
|
||||
|
||||
@@ -139,6 +139,35 @@ function imageRow(id: string, refId: string, text = ''): UserMessage {
|
||||
}
|
||||
|
||||
describe('QueueDock', () => {
|
||||
it.each(['ABC', 'ACB', 'BAC', 'BCA', 'CAB', 'CBA'])(
|
||||
'keeps repeated queued submissions in Dock through acceptance and FIFO claims (%s)', (hostOrder) => {
|
||||
const pending: SessionSnapshot['pendingSubmissions'] = ['A', 'B', 'C'].map(id => ({
|
||||
requestId: id as never, placement: 'queued', time: 1_000,
|
||||
text: `input ${id}`, attachments: [],
|
||||
}))
|
||||
const initial = { ...snapshotWith([]), pendingSubmissions: pending }
|
||||
const source = liveSession(initial)
|
||||
const view = render(<QueueDock {...kitFor(initial)} useSession={source.useSession} useProjection={source.useProjection} />)
|
||||
fireEvent.click(view.getByRole('button', { name: /3 条排队消息/ }))
|
||||
const order = () => [...view.container.querySelectorAll('[data-queue-dock] li')]
|
||||
.map(element => pending.find(input => element.textContent?.includes(input.text))!.requestId)
|
||||
expect(order()).toEqual(['A', 'B', 'C'])
|
||||
const queued: UserMessage[] = []
|
||||
for (const id of hostOrder) {
|
||||
const submission = pending.find(input => input.requestId === id)!
|
||||
queued.push({ ...row(id, submission.text), source: { kind: 'user', rpcId: submission.requestId } })
|
||||
const remaining = pending.filter(input => !hostOrder.slice(0, queued.length).includes(input.requestId))
|
||||
act(() => { source.push({ ...snapshotWith([...queued]), pendingSubmissions: remaining }) })
|
||||
expect(order()).toEqual([...queued.map(item => item.id), ...remaining.map(input => input.requestId)])
|
||||
}
|
||||
for (let claimed = 1; claimed <= queued.length; claimed++) {
|
||||
act(() => { source.push(snapshotWith(queued.slice(claimed))) })
|
||||
expect(order()).toEqual(hostOrder.slice(claimed).split(''))
|
||||
}
|
||||
expect(view.container.querySelector('[data-queue-dock]')).toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('renders null while the queue is empty', () => {
|
||||
const snap = snapshotWith([])
|
||||
const source = liveSession(snap)
|
||||
@@ -201,6 +230,28 @@ describe('QueueDock', () => {
|
||||
expect((view.getByRole('textbox') as HTMLTextAreaElement).value).toBe('等待上传')
|
||||
})
|
||||
|
||||
it('omits a local Chat submission while keeping other queued rows and queued echoes', () => {
|
||||
const local = { ...row('idle', '留在正文'), source: { kind: 'user' as const, rpcId: 'idle-request' as never } }
|
||||
const pending: TestSnapshot = {
|
||||
...snapshotWith([local, row('older', '原有排队')]),
|
||||
pendingSubmissions: [{
|
||||
requestId: 'idle-request' as never, placement: 'transcript', time: 1, text: '留在正文', attachments: [],
|
||||
}],
|
||||
}
|
||||
const source = liveSession(pending)
|
||||
const view = render(<QueueDock {...kitFor(pending)} useSession={source.useSession} useProjection={source.useProjection} />)
|
||||
expect(view.queryByText('留在正文')).toBeNull()
|
||||
expect(view.getByText('原有排队')).toBeTruthy()
|
||||
expect((view.getByRole('button', { name: '编辑排队消息' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
act(() => { source.push({ ...pending, pendingSubmissions: [...pending.pendingSubmissions, {
|
||||
requestId: 'queued-request' as never, placement: 'queued', time: 2, text: '新排队回显', attachments: [],
|
||||
}] }) })
|
||||
fireEvent.click(view.getByRole('button', { name: '2 条排队消息发送中…' }))
|
||||
expect(view.queryByText('留在正文')).toBeNull()
|
||||
expect(view.getByText('原有排队')).toBeTruthy()
|
||||
expect(view.getByText('新排队回显').closest('[data-submission-echo]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('loads the durable thumbnail after replacing a local image echo', async () => {
|
||||
const pending: TestSnapshot = {
|
||||
...snapshotWith([]),
|
||||
|
||||
@@ -462,7 +462,8 @@ describe('DockSurface', () => {
|
||||
expect(document.querySelector('[data-dockkit-split-button]')).toBeNull()
|
||||
})
|
||||
|
||||
it('names the chip box\'s hidden sides in data-dockkit-strip-scroll as it scrolls', () => {
|
||||
it.each([true, false])('names the chip box\'s hidden sides as it scrolls with resize observation: %s', (observed) => {
|
||||
if (!observed) vi.stubGlobal('ResizeObserver', undefined)
|
||||
let scrollLeft = 0
|
||||
const descriptors = ['scrollLeft', 'scrollWidth', 'clientWidth'].map(name =>
|
||||
[name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
|
||||
README.md: 43476f2aa531decbe1eff1b6664530f7873723b4
|
||||
README.zh.md: 44f5c6e123bd7d6e861fec9554beb2fccf827927
|
||||
README.md: 332845471056e718d49d79a077b76a02c79bafe2
|
||||
README.zh.md: 598b561e79e4a935f7f033002e68b91c3a3d84de
|
||||
@@ -79,6 +79,8 @@ Writing your own component in your own package is fine when the need is genuinel
|
||||
|
||||
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a cLine truncated
|
||||
|
||||
`Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. The bubble stays hidden until its first fit, slides inside the horizontal viewport margin, and flips above or below only when the opposite side fits. Label-size and viewport changes reuse the anchor coordinates; fitting does not synchronously measure the bubble or trigger a React render.
|
||||
|
||||
### Rendering agent output
|
||||
|
||||
The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener.
|
||||
|
||||
@@ -79,6 +79,8 @@ kind: "package-library"
|
||||
|
||||
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一Line truncated
|
||||
|
||||
`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前气泡保持隐藏;横向移入视口留白,仅在另一侧容得下时上下翻转。标签尺寸与视口变化复用锚点坐标;定位不会同步测量气泡,也不会触发 React 渲染。
|
||||
|
||||
### 渲染 agent 输出
|
||||
|
||||
最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Anchor-preserving tooltips with optional body portals for clipping containers. */
|
||||
|
||||
import { cloneElement, createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { cloneElement, createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import css from './Tooltip.module.css'
|
||||
@@ -53,7 +53,8 @@ if (typeof window !== 'undefined') {
|
||||
* @param props.maxWidth - bubble width cap in pixels, for labels long enough that the default
|
||||
* half-viewport cap would render a slab wider than the surface the anchor sits on.
|
||||
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
|
||||
* @returns the cloned anchor plus a fixed-position bubble, optionally portaled to the body; clicking the
|
||||
* @returns the cloned anchor plus a fixed-position bubble, optionally portaled to the body.
|
||||
* The bubble stays hidden until ResizeObserver supplies its size for viewport fitting; clicking the
|
||||
* anchor dismisses the bubble until the next trigger, and focus arriving after a pointer
|
||||
* interaction (a closing menu refocusing its trigger) never raises it.
|
||||
*/
|
||||
@@ -70,50 +71,15 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0,
|
||||
// The anchor's edges rather than final coordinates: a vertical flip has to
|
||||
// re-derive the bubble's own top from the opposite edge.
|
||||
const [pos, setPos] = useState<{ x: number; top: number; bottom: number } | null>(null)
|
||||
// Where the bubble actually sits, which is the requested side until the
|
||||
// viewport refuses it.
|
||||
const [placement, setPlacement] = useState<TooltipSide>(side)
|
||||
const bubble = useRef<HTMLSpanElement | null>(null)
|
||||
const resolvedLabel = pos === null
|
||||
? null
|
||||
: typeof label === 'function' ? label() : label
|
||||
const y = pos === null
|
||||
? 0
|
||||
: placement === 'right'
|
||||
: side === 'right'
|
||||
? pos.top + (pos.bottom - pos.top) / 2
|
||||
: placement === 'top' ? pos.top - 8 : pos.bottom + 8
|
||||
const EDGE_MARGIN = 12
|
||||
// Viewport fit: fixed positioning knows nothing about edges, so a centered
|
||||
// bubble near the right edge would clip and a long label under an anchor low
|
||||
// on the page would run off the bottom. Horizontally the bubble slides back
|
||||
// inside; vertically it flips to the opposite side, which is the only move
|
||||
// that does not cover the anchor being read. Each measurement resets the base
|
||||
// position first, so a shorter label or a larger viewport releases a previous
|
||||
// adjustment without another render.
|
||||
useLayoutEffect(() => {
|
||||
if (pos === null) return
|
||||
const fit = () => {
|
||||
const el = bubble.current
|
||||
/* v8 ignore next -- pos is set only while the bubble is mounted. */
|
||||
if (el === null) return
|
||||
el.style.left = `${pos.x}px`
|
||||
const r = el.getBoundingClientRect()
|
||||
let dx = 0
|
||||
if (r.right > window.innerWidth - EDGE_MARGIN) dx = window.innerWidth - EDGE_MARGIN - r.right
|
||||
if (r.left + dx < EDGE_MARGIN) dx = EDGE_MARGIN - r.left
|
||||
el.style.left = `${pos.x + dx}px`
|
||||
if (side === 'right') return
|
||||
// Flip only into a side that genuinely fits, so an anchor with room on
|
||||
// neither side keeps the requested placement instead of oscillating.
|
||||
const fitsBelow = pos.bottom + 8 + r.height <= window.innerHeight - EDGE_MARGIN
|
||||
const fitsAbove = pos.top - 8 - r.height >= EDGE_MARGIN
|
||||
if (placement === 'bottom' && !fitsBelow && fitsAbove) setPlacement('top')
|
||||
if (placement === 'top' && !fitsAbove && fitsBelow) setPlacement('bottom')
|
||||
}
|
||||
fit()
|
||||
window.addEventListener('resize', fit)
|
||||
return () => { window.removeEventListener('resize', fit) }
|
||||
}, [placement, pos, resolvedLabel, side])
|
||||
: side === 'top' ? pos.top - 8 : pos.bottom + 8
|
||||
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// Hover and focus are independent triggers: the bubble hides only after
|
||||
// BOTH clear (hovering away from a focused anchor must not drop it).
|
||||
@@ -128,6 +94,40 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0,
|
||||
const [suppressed, setSuppressed] = useState(false)
|
||||
const announce = useCallback((active: boolean) => { suppressAncestors?.(active) }, [suppressAncestors])
|
||||
const visible = pos !== null && !disabled
|
||||
// ResizeObserver supplies the laid-out border box; fitting never reads
|
||||
// geometry after a position write or needs a React commit to flip sides.
|
||||
useEffect(() => {
|
||||
const el = bubble.current
|
||||
if (pos === null || !visible || suppressed || el === null) return
|
||||
const edgeMargin = 12
|
||||
let size: ResizeObserverSize | undefined
|
||||
let placement = side
|
||||
const fit = () => {
|
||||
if (size === undefined) return
|
||||
const { inlineSize: width, blockSize: height } = size
|
||||
const offset = side === 'right' ? 0 : align === 'end' ? width : width / 2
|
||||
const left = Math.max(edgeMargin, Math.min(pos.x - offset, window.innerWidth - edgeMargin - width))
|
||||
const fitsBelow = pos.bottom + 8 + height <= window.innerHeight - edgeMargin
|
||||
const fitsAbove = pos.top - 8 - height >= edgeMargin
|
||||
if (placement === 'bottom' && !fitsBelow && fitsAbove) placement = 'top'
|
||||
else if (placement === 'top' && !fitsAbove && fitsBelow) placement = 'bottom'
|
||||
el.style.left = `${left + offset}px`
|
||||
el.style.top = `${placement === 'right' ? (pos.top + pos.bottom) / 2
|
||||
: placement === 'top' ? pos.top - 8 : pos.bottom + 8}px`
|
||||
el.dataset.side = placement
|
||||
el.style.visibility = 'visible'
|
||||
}
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
size = entries[0]?.borderBoxSize[0]
|
||||
fit()
|
||||
})
|
||||
observer.observe(el, { box: 'border-box' })
|
||||
window.addEventListener('resize', fit)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
window.removeEventListener('resize', fit)
|
||||
}
|
||||
}, [align, pos, side, suppressed, visible])
|
||||
useEffect(() => {
|
||||
announce(visible)
|
||||
return () => { announce(false) }
|
||||
@@ -155,9 +155,6 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0,
|
||||
/* v8 ignore next -- the ref is attached by event time: events fire on the cloned anchor. */
|
||||
if (el === null) return
|
||||
const r = el.getBoundingClientRect()
|
||||
// Every show starts from the requested side; the fit pass flips it only
|
||||
// where this anchor's position demands it.
|
||||
setPlacement(side)
|
||||
setPos({
|
||||
x: side === 'right' ? r.right + 10 : align === 'end' ? r.right : r.left + r.width / 2,
|
||||
top: r.top,
|
||||
@@ -189,10 +186,10 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0,
|
||||
<span
|
||||
ref={bubble}
|
||||
className={css.bubble}
|
||||
data-side={placement}
|
||||
data-side={side}
|
||||
data-portal={portal || undefined}
|
||||
data-align={align}
|
||||
style={{ left: pos.x, top: y, ...maxWidth === undefined ? {} : { maxWidth } }}
|
||||
style={{ left: pos.x, top: y, visibility: 'hidden', ...maxWidth === undefined ? {} : { maxWidth } }}
|
||||
role="tooltip"
|
||||
>
|
||||
{resolvedLabel}
|
||||
|
||||
@@ -1,11 +1,66 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
afterEach(cleanup)
|
||||
let bubbleSize: ResizeObserverSize
|
||||
let automaticResize: boolean
|
||||
const observers: TooltipResizeObserver[] = []
|
||||
|
||||
class TooltipResizeObserver implements ResizeObserver {
|
||||
private target: Element | undefined
|
||||
constructor(private readonly callback: ResizeObserverCallback) { observers.push(this) }
|
||||
observe(target: Element): void {
|
||||
this.target = target
|
||||
if (automaticResize) this.deliver()
|
||||
}
|
||||
unobserve(): void { this.target = undefined }
|
||||
disconnect(): void { this.target = undefined }
|
||||
deliver(): void {
|
||||
if (this.target === undefined) return
|
||||
this.callback([{
|
||||
target: this.target, borderBoxSize: [bubbleSize], contentBoxSize: [bubbleSize],
|
||||
devicePixelContentBoxSize: [bubbleSize],
|
||||
contentRect: new DOMRect(0, 0, bubbleSize.inlineSize, bubbleSize.blockSize),
|
||||
}], this)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
bubbleSize = { inlineSize: 0, blockSize: 0 }
|
||||
automaticResize = true
|
||||
observers.length = 0
|
||||
vi.stubGlobal('ResizeObserver', TooltipResizeObserver)
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Tooltip', () => {
|
||||
it('fits from observed sizes without synchronously measuring the bubble', () => {
|
||||
automaticResize = false
|
||||
const measured = vi.spyOn(Element.prototype, 'getBoundingClientRect')
|
||||
const view = render(<Tooltip label="Observed" side="bottom"><button>anchor</button></Tooltip>)
|
||||
const anchor = screen.getByText('anchor')
|
||||
fireEvent.mouseEnter(anchor)
|
||||
const bubble = view.container.querySelector<HTMLElement>('[role="tooltip"]')!
|
||||
expect(measured.mock.contexts).toEqual([anchor])
|
||||
expect(bubble.style.visibility).toBe('hidden')
|
||||
fireEvent(window, new Event('resize'))
|
||||
expect(bubble.style.visibility).toBe('hidden')
|
||||
expect(measured.mock.contexts).toEqual([anchor])
|
||||
bubbleSize = { inlineSize: 100, blockSize: 20 }
|
||||
act(() => { observers[0]!.deliver() })
|
||||
expect(screen.getByRole('tooltip').style.left).toBe('62px')
|
||||
fireEvent(window, new Event('resize'))
|
||||
expect(measured.mock.contexts).toEqual([anchor])
|
||||
const disconnect = vi.spyOn(observers[0]!, 'disconnect')
|
||||
view.unmount()
|
||||
expect(disconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('resolves lazy labels only after the bubble becomes visible', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -64,9 +119,7 @@ describe('Tooltip', () => {
|
||||
const bubble = screen.getByRole('tooltip')
|
||||
expect(bubble.textContent).toBe('Open sidebar')
|
||||
expect(bubble.getAttribute('data-side')).toBe('right')
|
||||
// jsdom rects are all-zero: right placement lands at the +10 gutter, then
|
||||
// the zero-width measured rect clamps to the 12px edge margin (10 + 12).
|
||||
expect(bubble.style.left).toBe('22px')
|
||||
expect(bubble.style.left).toBe('12px')
|
||||
expect(bubble.style.top).toBe('0px')
|
||||
fireEvent.mouseLeave(anchor)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
@@ -89,13 +142,12 @@ describe('Tooltip', () => {
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
// jsdom's default rects are all-zero, so the clamp tests stub the measured
|
||||
// rect (anchor and bubble share the prototype stub) and derive expectations
|
||||
// from it: pos.x = anchor center, then shifted by the measured overflow.
|
||||
// Anchor geometry and observed bubble sizes are independent browser inputs.
|
||||
const rect = (left: number, right: number): DOMRect =>
|
||||
({ left, right, top: 0, bottom: 20, width: right - left, height: 20, x: left, y: 0, toJSON: () => ({}) })
|
||||
|
||||
it('aligns the end of a bottom tooltip with the anchor right edge', () => {
|
||||
bubbleSize = { inlineSize: 100, blockSize: 20 }
|
||||
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(100, 200))
|
||||
try {
|
||||
render(
|
||||
@@ -125,6 +177,7 @@ describe('Tooltip', () => {
|
||||
})
|
||||
|
||||
it('clamps a bubble overflowing the right viewport edge back inside', () => {
|
||||
bubbleSize = { inlineSize: 200, blockSize: 20 }
|
||||
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(900, 1100))
|
||||
try {
|
||||
render(
|
||||
@@ -144,10 +197,8 @@ describe('Tooltip', () => {
|
||||
|
||||
it('reclamps after label and viewport width changes', () => {
|
||||
const originalWidth = window.innerWidth
|
||||
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
if (this.getAttribute('role') !== 'tooltip') return rect(900, 1000)
|
||||
return this.textContent === 'Wide' ? rect(900, 1100) : rect(850, 950)
|
||||
})
|
||||
bubbleSize = { inlineSize: 200, blockSize: 20 }
|
||||
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(900, 1000))
|
||||
try {
|
||||
const view = render(
|
||||
<Tooltip label="Wide" side="bottom">
|
||||
@@ -155,18 +206,22 @@ describe('Tooltip', () => {
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
expect(screen.getByRole('tooltip').style.left).toBe('862px')
|
||||
expect(screen.getByRole('tooltip').style.left).toBe('912px')
|
||||
|
||||
view.rerender(
|
||||
<Tooltip label="Short" side="bottom">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
bubbleSize = { inlineSize: 100, blockSize: 20 }
|
||||
act(() => { observers[0]!.deliver() })
|
||||
expect(screen.getByRole('tooltip').style.left).toBe('950px')
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 900 })
|
||||
fireEvent(window, new Event('resize'))
|
||||
expect(screen.getByRole('tooltip').style.left).toBe('888px')
|
||||
expect(screen.getByRole('tooltip').style.left).toBe('838px')
|
||||
expect(observers).toHaveLength(1)
|
||||
expect(spy).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth })
|
||||
spy.mockRestore()
|
||||
@@ -174,6 +229,7 @@ describe('Tooltip', () => {
|
||||
})
|
||||
|
||||
it('clamps a bubble past the left viewport edge back inside', () => {
|
||||
bubbleSize = { inlineSize: 100, blockSize: 20 }
|
||||
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(-20, 80))
|
||||
try {
|
||||
render(
|
||||
@@ -190,16 +246,12 @@ describe('Tooltip', () => {
|
||||
}
|
||||
})
|
||||
|
||||
/** Anchor and bubble rects, so a placement test measures real room rather than jsdom's all-zero boxes. */
|
||||
const placed = (anchorTop: number, anchorBottom: number, bubbleHeight: number) =>
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
const [top, bottom] = this.getAttribute('role') === 'tooltip'
|
||||
? [0, bubbleHeight]
|
||||
: [anchorTop, anchorBottom]
|
||||
return {
|
||||
left: 100, right: 200, top, bottom, width: 100, height: bottom - top, x: 100, y: top, toJSON: () => ({}),
|
||||
}
|
||||
const placed = (top: number, bottom: number, bubbleHeight: number) => {
|
||||
bubbleSize = { inlineSize: 100, blockSize: bubbleHeight }
|
||||
return vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 100, right: 200, top, bottom, width: 100, height: bottom - top, x: 100, y: top, toJSON: () => ({}),
|
||||
})
|
||||
}
|
||||
|
||||
it('supports top placement for anchors at the viewport bottom', () => {
|
||||
const spy = placed(700, 720, 20)
|
||||
|
||||
@@ -66,6 +66,23 @@ it('resolves missing and small content to 100 percent', () => {
|
||||
expect(fitWidthZoom(200, 100, 24)).toBe(1)
|
||||
})
|
||||
|
||||
it('fits the initial pane and allows fixed zoom without resize observation', () => {
|
||||
vi.stubGlobal('ResizeObserver', undefined)
|
||||
const onPreference = vi.fn()
|
||||
render(<ZoomViewport preference={FIT_WIDTH} intrinsicWidth={1000} horizontalInset={20}
|
||||
labels={{ controls: 'Zoom controls', menu: 'Choose zoom', out: 'Zoom out', into: 'Zoom in',
|
||||
fitWidth: 'Fit width', value: percent => `${String(percent)}%` }} signal={new AbortController().signal}
|
||||
scrollportRef={() => {}} onPreference={onPreference}>
|
||||
<div className={zoomSurfaceClass} />
|
||||
</ZoomViewport>)
|
||||
const menu = screen.getByRole('button', { name: 'Choose zoom' })
|
||||
expect(menu.textContent).toContain('48%')
|
||||
fireEvent.click(menu)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '100%' }))
|
||||
expect(onPreference).toHaveBeenCalledExactlyOnceWith({ kind: 'fixed', scale: 1 })
|
||||
expect(menu.textContent).toContain('100%')
|
||||
})
|
||||
|
||||
it('keeps a centred surface point under the pointer while crossing into overflow', () => {
|
||||
const onPreference = vi.fn()
|
||||
const view = render(<ZoomViewport preference={{ kind: 'fixed', scale: 0.5 }} intrinsicWidth={400}
|
||||
|
||||
@@ -6211,7 +6211,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionFollowRequest',
|
||||
declaration: 'export interface SessionFollowRequest {\n readonly address: SessionAddress;\n readonly maxMessages?: number;\n readonly assistantStream?: true;\n}',
|
||||
declaration: 'export interface SessionFollowRequest extends Pick<SessionPageRequest, \'maxMessages\' | \'turnWindow\'> {\n readonly address: SessionAddress;\n readonly assistantStream?: true;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionForkRequest',
|
||||
@@ -6315,7 +6315,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionPageRequest',
|
||||
declaration: 'export interface SessionPageRequest {\n readonly address: SessionAddress;\n readonly throughSeq: number;\n readonly beforeSeq?: number;\n readonly maxMessages?: number;\n}',
|
||||
declaration: 'export interface SessionPageRequest {\n readonly address: SessionAddress;\n readonly throughSeq: number;\n readonly beforeSeq?: number;\n readonly maxMessages?: number;\n readonly turnWindow?: {\n readonly minMessages: number;\n readonly minTurns: number;\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionPersistenceCreateOptions',
|
||||
|
||||
@@ -32,6 +32,9 @@ async function started(plan: AssemblyPlan, options?: TestClientOptions): Promise
|
||||
|
||||
describe('TestClient (jsdom)', () => {
|
||||
it('boots the whole web-app roster, connects, mounts, and disposes with nothing unmatched', async () => {
|
||||
// Exercise missing-API cleanup independently of the suite's browser defaults.
|
||||
vi.stubGlobal('ResizeObserver', undefined)
|
||||
onTestFinished(() => { vi.unstubAllGlobals() })
|
||||
const mock = RemoteMock.create().load(remoteDefaultResponses)
|
||||
const client = await TestClient.start({ roster: webApp }, mock, { mount: true })
|
||||
expect(client.connection.state.getSnapshot()).toBe('connected')
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Initial resize delivery from jsdom's modeled offsets; geometry/timing suites supply their own observers. */
|
||||
import { afterAll, beforeEach } from 'vitest'
|
||||
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'ResizeObserver')
|
||||
let installed = false
|
||||
|
||||
class TestResizeObserver implements ResizeObserver {
|
||||
private readonly targets = new Set<Element>()
|
||||
|
||||
constructor(private readonly callback: ResizeObserverCallback) {}
|
||||
|
||||
observe(target: Element): void {
|
||||
if (this.targets.has(target)) return
|
||||
this.targets.add(target)
|
||||
const width = target instanceof HTMLElement ? target.offsetWidth : 0
|
||||
const height = target instanceof HTMLElement ? target.offsetHeight : 0
|
||||
const size = [{ inlineSize: width, blockSize: height }]
|
||||
this.callback([{
|
||||
target, contentRect: new DOMRectReadOnly(0, 0, width, height),
|
||||
borderBoxSize: size, contentBoxSize: size, devicePixelContentBoxSize: size,
|
||||
}], this)
|
||||
}
|
||||
|
||||
unobserve(target: Element): void { this.targets.delete(target) }
|
||||
|
||||
disconnect(): void { this.targets.clear() }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
if (typeof document !== 'undefined' && typeof ResizeObserver === 'undefined') {
|
||||
// Establish the per-environment default, so vi.unstubAllGlobals restores it.
|
||||
Object.defineProperty(globalThis, 'ResizeObserver', { configurable: true, writable: true, value: TestResizeObserver })
|
||||
installed = true
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (!installed) return
|
||||
if (original === undefined) Reflect.deleteProperty(globalThis, 'ResizeObserver')
|
||||
else Object.defineProperty(globalThis, 'ResizeObserver', original)
|
||||
})
|
||||
@@ -2,6 +2,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('Vitest jsdom compatibility', () => {
|
||||
it('provides initial ResizeObserver delivery and supports disconnecting targets', () => {
|
||||
const target = document.createElement('div')
|
||||
target.getBoundingClientRect = () => { throw new Error('the jsdom resize fallback must not measure viewport geometry') }
|
||||
const deliveries: ResizeObserverEntry[][] = []
|
||||
const observer = new ResizeObserver(entries => deliveries.push(entries))
|
||||
observer.observe(target, { box: 'border-box' })
|
||||
observer.observe(target)
|
||||
expect(deliveries).toHaveLength(1)
|
||||
expect(deliveries[0]?.[0]).toMatchObject({ target, borderBoxSize: [{ inlineSize: 0, blockSize: 0 }] })
|
||||
observer.unobserve(target)
|
||||
observer.observe(target)
|
||||
expect(deliveries).toHaveLength(2)
|
||||
observer.disconnect()
|
||||
observer.observe(target)
|
||||
expect(deliveries).toHaveLength(3)
|
||||
observer.disconnect()
|
||||
})
|
||||
|
||||
it('provides isolated browser storage instead of Node process storage', () => {
|
||||
if (process.allowedNodeEnvironmentFlags.has('--webstorage')) {
|
||||
expect(process.execArgv.filter(argument => argument === '--no-webstorage')).toHaveLength(1)
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
"apps/web/tests/markdown-inline-code-links.e2e.ts",
|
||||
"apps/web/tests/clickable-links-gallery.e2e.ts",
|
||||
"apps/web/tests/queue-actions.e2e.ts",
|
||||
"apps/web/tests/idle-submission-handoff.e2e.ts",
|
||||
"apps/web/tests/queue-image.e2e.ts",
|
||||
"apps/web/tests/skill-invocation-policy.e2e.ts",
|
||||
"apps/web/tests/skill-user-invoke.e2e.ts",
|
||||
|
||||
+3
-3
@@ -161,7 +161,7 @@ const processBoundTests = [
|
||||
export default defineConfig({
|
||||
plugins: [pathsPlugin(), standardDecoratorPlugin()],
|
||||
test: {
|
||||
setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts'],
|
||||
setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts', './scripts/test-dom-environment.ts'],
|
||||
// .tsx: client component specs (jsdom via per-file @vitest-environment pragma).
|
||||
include: testIncludes,
|
||||
exclude: platformUnsupportedTests,
|
||||
@@ -177,7 +177,7 @@ export default defineConfig({
|
||||
// MaybeLocal in cjs_lexer::Parse) from worker threads on macOS,
|
||||
// Linux, and Windows. Forked workers avoid that shared thread path.
|
||||
pool: 'forks',
|
||||
setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts'],
|
||||
setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts', './scripts/test-dom-environment.ts'],
|
||||
include: testIncludes,
|
||||
exclude: [
|
||||
...platformUnsupportedTests,
|
||||
@@ -192,7 +192,7 @@ export default defineConfig({
|
||||
name: 'process-bound',
|
||||
execArgv: vitestExecArgv,
|
||||
pool: 'forks',
|
||||
setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts'],
|
||||
setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts', './scripts/test-dom-environment.ts'],
|
||||
include: processBoundTests,
|
||||
exclude: [
|
||||
...platformUnsupportedTests,
|
||||
|
||||
Reference in new issue
Block a user