diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml index 97834be19a..b34903faef 100644 --- a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml @@ -15,11 +15,11 @@ en: db8d77cd773ad646 zh: ab2f03b8fc58e952 /agent-note-chat-group-tree-positions-and-exchanges/alternatives-considered: - en: a2b48738666a7d5f - zh: 53377f4f1109af26 + en: fd9152c3f5edb035 + zh: 259337cdcc5969a5 /agent-note-chat-group-tree-positions-and-exchanges/consequences: - en: f51678f9f30bad59 - zh: b525e9ed10b9928c + en: 2a98bb21bb1315d1 + zh: be4a320e9b427a79 /agent-note-chat-group-tree-positions-and-exchanges/testing: en: e2e3982718e16ef4 zh: 4f3802946e51c55c diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md index c30483a404..0fffb84e71 100644 --- a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md @@ -33,7 +33,7 @@ The host claims the waking input right after `turn/start`, assembles the system **Declare a business role (`input`, `notice`) on Nodes.** That places business semantics in the grouping mechanism; group-tree positions describe structure only, and each business Definition chooses its position. -**Correct the scroll room for folds not caused by own input.** Notices should not fold at all, and correcting the room would not remove that unnecessary fold; it remains a residual issue for human input from another tab. +**Correct the scroll room for folds not caused by own input.** Notices should not fold at all, and correcting the room would not remove that unnecessary fold. Folds caused by human input without an echo on this page are handled by [one fold motion for collapsing on the next message](2026-10-13-chat-single-fold-motion.md). **Read Turn inputs at `turn`.** Terminal errors, max-token notices, and the completed-turn footer also stand outside the process disclosure, so reading `turn` would treat them as opening input; Turn inputs need their own position. @@ -45,7 +45,7 @@ The host claims the waking input right after `turn/start`, assembles the system - External Definitions place their Chat rows through `groupTree`. Apart from the two `assistant-step` rules, ui-chat names no kind when it groups, folds, or detects Turn inputs, and built-in kinds keep the placement their former kind lists gave them. - Under On next message, a long run of notice-woken Turns (up to 31 goal-mode Turns in sampled Sessions) stays expanded until the human types again. - When the loaded window holds no opening Turn, every loaded Turn belongs to the latest exchange. -- Human input from another tab folds when its `user/message` arrives, about 200 ms later than the former `turn/start` fold; that fold still uses generic room and is unchanged here. +- Human input from another tab folds when its `user/message` arrives, about 200 ms later than the former `turn/start` fold; its landing follows [one fold motion for collapsing on the next message](2026-10-13-chat-single-fold-motion.md). ## Testing diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md index bd3375f5dc..395faed58b 100644 --- a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md @@ -33,7 +33,7 @@ Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示 **节点声明业务角色(`input`、`notice`)。** 这会把业务语义放进分组机制;分组树位置只描述结构,业务 Definition 自行决定位置。 -**修正非自身输入折叠时的滚动补白。** 通知本来就不应触发折叠,修补白不能消除这次不必要的折叠;它仍是另一个标签页里真人输入的残留问题。 +**修正非自身输入折叠时的滚动补白。** 通知本来就不应触发折叠,修补白不能消除这次不必要的折叠。本页没有回显的真人输入引起的折叠,由[「下条消息时收起」只用一条折叠动画](2026-10-13-chat-single-fold-motion.zh.md)处理。 **在 `turn` 位置上判断轮次输入。** 终止错误、最大 token 提示和已完成轮次页脚同样在整轮折叠之外,读 `turn` 会把它们误当作开场输入;轮次输入需要自己的位置。 @@ -45,7 +45,7 @@ Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示 - 外部 Definition 通过 `groupTree` 放置自己的 Chat 行。除 `assistant-step` 的两条规则外,ui-chat 在分组、折叠和判断轮次输入时不点名任何 kind,内置 kind 保持原 kind 名单给出的放置。 - 「下次发送时收起」下,一长串通知唤醒的 Turn(抽样会话中 Goal 模式最长 31 轮)会一直展开,直到真人再次输入。 - 已加载窗口里没有开启 Turn 时,所有已加载 Turn 都属于最新交互段。 -- 另一个标签页里的真人输入在它的 `user/message` 到达时才折叠,比原来在 `turn/start` 时晚约 200 ms;这类折叠仍使用通用补白,本次没有修改。 +- 另一个标签页里的真人输入在它的 `user/message` 到达时才折叠,比原来在 `turn/start` 时晚约 200 ms;其落点按[「下条消息时收起」只用一条折叠动画](2026-10-13-chat-single-fold-motion.zh.md)决定。 ## Testing diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml new file mode 100644 index 0000000000..be7bf32098 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml @@ -0,0 +1,31 @@ +# Bilingual-pair consistency record for 2026-10-13-chat-single-fold-motion.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md +/agent-note-one-fold-motion-for-collapsing-on-the-next-message: + en: 45594545dabfaa78 + zh: a1eac669647b2bac +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/problem: + en: 88faaf0c589e12cb + zh: dda7efc5eaed2788 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision: + en: 972bbe45fe8ac28e + zh: 593b851c142894e1 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/batches: + en: f44b784545c6d9a2 + zh: 3eb6fcb9f348dec4 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/landings: + en: b8278678170f1507 + zh: 113a51ba5cf27de1 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/own-input: + en: ca6a5c372bb3cad1 + zh: 1cd5028269b58c65 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/testing: + en: 0423a607ee0589b5 + zh: 61df892a01c5aa43 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/alternatives-considered: + en: d8e1ea66e293cd93 + zh: 0aaa9e693f441673 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/consequences: + en: 89148df30a9a1772 + zh: d484cc7c9e0cc3f1 diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md new file mode 100644 index 0000000000..8c279abb34 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md @@ -0,0 +1,92 @@ +# Agent Note: One fold motion for collapsing on the next message + +Status: implemented + +English | [中文](2026-10-13-chat-single-fold-motion.zh.md) + +## Problem + +When "When to Collapse Work Details" is "On next message", the previous exchange's process rows close when the next human input arrives. The scroll layer had two fold paths and chose one by `ownInput` ([use-chat-scroll.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts)): + +- **Input fold path:** when the fold starts, it measures the closing rows, computes the final landing and the required bottom room, and moves the rows and the scrollport on one frame clock to a single landing. +- **Generic path:** scrollTop stays put, the closing height is reserved as bottom room in one block, and following scrolls once more after the fold ends. This holds only when the fold happens below the reader. When the folding region is above the reader, the content the reader is looking at moves up as a whole and the viewport shows only the room. + +`ownInput` holds for three signals only: a local echo in the transcript (`submissionId`), a steer echo (`steeringId`), or a user message as the last list row (`lastIsUser`). In the commit where the Host records the opening input, the Turn's process control is ordered after that input, so `lastIsUser` does not hold. Every human input without a transcript echo on this page therefore took the generic path: + +| Source | Echo on this page | Reader at the bottom | Reader in the middle of the body | +|---|---|---|---| +| Idle send from the main composer | Yes, in the transcript | Correct | Correct | +| Main-Session queued message delivered after the previous Turn | Yes, but in the queue, not consulted | Whole viewport blank | Whole viewport blank | +| Send from another tab | No | Whole viewport blank | Whole viewport blank | +| Send from a subagent child's composer in the right Sidebar | No (the composer skipped `beginSubmission`) | Whole viewport blank | Whole viewport blank | +| Human send after parent messages woke several child Turns | No | Whole viewport blank (about 6000 px of room) | — | + +Notice-woken Turns no longer fold anything ([group-tree positions and exchanges](2026-10-11-chat-group-tree-exchanges.md)); the table above covers the human input that still folded through the generic path. The data comes from [fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts). + +Subagent children showed no local echo because, when echoes were introduced, `subagents.prompt` carried no request id, so an echo could not be matched with the message the child recorded. Subagent control later moved to Remote, and the Host now records `SubagentPromptRequest.requestId` as the child message's `source.rpcId`; the client's `Session.prompt` still minted a separate id. + +## Decision + +"On next message" keeps a single fold motion: [flow-motion.ts](../../../../packages/client/ui-chat/src/client/chat/flow-motion.ts) hands every batch of closing rows to the `InputFoldDriver` that the viewport supplies, and [use-chat-viewport.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) chooses one of two landings when the batch begins. + +### Batches + +- Rows that start closing in one commit, together with rows that start revealing in it, form one batch. The driver's four steps are: + - `begin`: measure the boxes and choose the landing; + - `prepare`: grow the room before box writes so the browser does not clamp an intermediate frame; + - `frame`: write box heights and scrollTop together; + - `finish`: install the final room. + + Content added during the batch updates the remaining movement by starting a new segment. +- A row that starts closing or revealing while a batch runs keeps its current size, without a transition of its own, until that batch settles; the next flush drives it in a new batch, or starts a held revealing row's own transition when nothing closes. Rows therefore never change outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. A batch takes over every member at its current size: a closing row shrinks from the height it has now, and a revealing row grows from the height its own transition already reached, so only the remaining growth enters the landing; a closing row with no size left finishes without animation. +- A batch with only revealing rows needs no room and keeps CSS transitions. When the viewport has no reading policy attached, closing rows also keep CSS transitions, and held rows finish immediately; under reduced motion closing rows finish immediately. +- Closing rows commit their hidden state first, the driver then installs the final geometry, and only then are fold-idle listeners notified (for example, clearing room after a display-mode change). +- The generic path is removed: the `reserve` callback, the `pendingReserve` and `pendingGrowth` reservation arithmetic, `reserveBelow`, and the post-fold `followAfterFold`. `reclaimBelow` (returning room after new content or reader scrolling) and `resetBelow` (clearing room after a display-mode change) remain. + +### Landings + +Let `start` be the scrollTop when the batch begins, `above` the height of closing rows above the viewport top minus revealing rows there, `base = max(0, start − above)`, and `bottom` the floor after the fold. + +| Landing | When | Target scrollTop | After the batch | +|---|---|---|---| +| **Tail** | This client's own input, or the reader was following the tail when the batch began and had no reading movement awaiting sampling | `max(base, bottom)` | Commit follow ownership | +| **Reader content still** | Otherwise | `base` | Keep the reader's reading state and acknowledge the new position | + +- Both landings install only the exact deficit as room: `max(0, target + viewport height − folded height)`. The tail landing reads the committed layout when it finishes: it lands at the larger of the target and the real floor, and keeps only the room that target needs. +- Content never slides back down: content already above the viewport top does not return. When the retained body and the new message cannot fill the viewport, the deficit remains as room until new content fills it. +- Own input during a running batch starts a new segment from the current position toward the tail; it takes over even when a reading gesture cancelled the automatic target. A tail request counts only while a batch runs or is scheduled; rows still closing on CSS transitions after a batch leave own input to ordinary following. +- A reading gesture cancels a tail landing's automatic target and keeps the reader's position. During a reader-content-still landing, the reader's own scrolling between frames moves the position the batch keeps still. +- For the reader-content-still landing, the driver takes the first visible top-level row outside the batch as a reference, found by binary search over the ordered top-level rows. Neighbouring spacing changes instantly when the batch begins and when closing rows commit their hidden state; the driver measures the reference's displacement at those two points and offsets scrollTop by it. +- When a tail landing scrolls forward past closing rows that start below the viewport top, those rows stay opaque, scroll with the content, and clip while they shrink; nested closing rows keep their size until the batch ends. Other closing rows fade in place. A newer tail request that takes over a running batch adds the closing rows still below the viewport top; a row that has already faded becomes opaque again for its remaining height. Scrolling forward therefore never crosses space that is already transparent but not yet closed. + +### Own input + +| Source | Determination | Landing | +|---|---|---| +| Idle send from the main composer | Transcript echo | Tail | +| Idle send from a subagent child's composer | The child also registers a local echo; `subagents.prompt` uses the echo's `requestId` | Tail | +| Queued while running, delivered after the previous Turn | Not own input | Tail when following, otherwise reader content still | +| Send from another tab | Not own input | Same as above | +| Parent Agent messages a child | The notice opens no exchange and folds nothing | — | + +"On completion" is outside the motion scope; its folds apply immediately and are unaffected. + +## Testing + +[fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts) covers 19 main-Session cases and 8 right-Sidebar child cases across reader position (bottom, middle of the body, top of the body, inside the steps, previous Turn), input source (composer, Turn failing before its message is recorded, another tab, queued delivery, steer, parent message, several Turns folding at once), body length, and Standard and "On completion" controls. The layout gap is the tallest band between the scrollport top and the composer that no visible row's box covers: at most 120 px once settled, and over that for at most one animation frame. It counts boxes, so a closing row that has faded but not finished shrinking still covers its space. Tail cases assert the floor landing; reader-content-still cases assert that the reader's anchor row moves at most 2 px on screen. Short-body cases assert only that no room remains after the reply arrives. + +## Alternatives considered + +**Keep the generic path and fix only own-input detection.** The two paths keep diverging geometry rules; for input that is not own input, a reader in the middle is either pulled to the bottom or still sees a blank. + +**Compensate the generic path by reader position but implement it separately from the input path.** The compensation and room geometry would exist twice and could diverge again. + +**Detect own input from the exchange-opening human node and keep children without echoes.** Input from another tab would count as own input and pull a reader in the middle to the bottom; child sends still would not show the sender's message immediately. + +**Let content slide back to fill the deficit a short body leaves.** Content would move up and then down within one animation. + +## Consequences + +- Benefit: the source of whole-viewport blanks (the generic path) no longer exists; the landing depends only on whether the input is own input and whether the reader was following, not on which page sent the message or when it was delivered. Child sends show the sender's message immediately. +- Cost: with a short body, content never sliding back still leaves the deficit until new content fills it. +- When focus is inside a closing row, focus protection keeps that row open. If it is a height-capped process group, its body shrinks to the cap immediately. That shrink happens before the batch begins, so the reader-content-still landing cannot measure it and the reader's content moves once. diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md new file mode 100644 index 0000000000..96e36590e7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md @@ -0,0 +1,92 @@ +# Agent Note: 「下条消息时收起」只用一条折叠动画 + +Status: implemented + +[English](2026-10-13-chat-single-fold-motion.md) | 中文 + +## Problem + +「何时收起工作详情」选「下条消息时收起」时,上一个交互段的过程行在下一条人类输入到来时收起。滚动层原本有两条折叠路径,按 `ownInput`([use-chat-scroll.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts))二选一: + +- **输入折叠路径**:折叠开始时测量收起行,算出最终落点和所需的底部补白,收起与滚动在同一个帧时钟上推进,一次落定。 +- **通用路径**:scrollTop 不动,把收起的高度整块预留为底部补白,折叠结束后再按跟随状态补滚一次。它只对折叠发生在读者下方的情况成立。折叠区在读者上方时,读者正在看的内容整体上移,视口里只剩补白。 + +`ownInput` 只在三种信号下成立:对话流里的本地回显(`submissionId`)、steer 回显(`steeringId`)、列表最后一行是用户消息(`lastIsUser`)。Host 写回开场输入的那次提交里,该轮的过程控制行排在输入之后,`lastIsUser` 不成立。于是,凡是不经过本页对话流回显的人类输入都走通用路径: + +| 来源 | 本页回显 | 读者在底部 | 读者在正文中段 | +|---|---|---|---| +| 主会话输入框空闲发送 | 有,在对话流 | 正常 | 正常 | +| 主会话排队消息在上一轮结束后自动投递 | 有,但在排队区,不参与判定 | 整屏空白 | 整屏空白 | +| 另一个标签页发送 | 无 | 整屏空白 | 整屏空白 | +| 右侧栏子会话输入框发送 | 无(composer 跳过 `beginSubmission`) | 整屏空白 | 整屏空白 | +| 子会话里父消息唤醒多轮后人类发送 | 无 | 整屏空白(补白约 6000 px) | — | + +通知唤醒的 Turn 已不再触发折叠([分组树位置与交互段](2026-10-11-chat-group-tree-exchanges.zh.md));上表是仍经由通用路径折叠的人类输入。数据来自 [fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts)。 + +子会话不显示本地回显,是因为引入回显时 `subagents.prompt` 不携带 request id,回显无法与子会话写回的消息对应。此后子 agent 控制迁到 Remote,`SubagentPromptRequest.requestId` 已被 Host 写入子会话用户消息的 `source.rpcId`;客户端 `Session.prompt` 却仍另行生成 id。 + +## Decision + +「下条消息时收起」只保留一条折叠动画:[flow-motion.ts](../../../../packages/client/ui-chat/src/client/chat/flow-motion.ts) 把每一批收起交给 viewport 提供的 `InputFoldDriver`,[use-chat-viewport.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) 在批次开始时选择两种落点之一。 + +### 批次 + +- 同一次提交中开始收起的行,连同同批开始展开的行,组成一个批次。驱动器的四步依次为: + - `begin`:测量这些盒子,选定落点; + - `prepare`:在写盒子之前扩大补白,避免中间帧被浏览器截断; + - `frame`:同步写盒子高度和 scrollTop; + - `finish`:安装最终补白。 + + 批次中途新增的内容按分段规则更新剩余运动。 +- 批次运行期间开始收起或展开的行保持当前尺寸、不走自己的过渡,等当前批次落定后由下一次刷新处理:有收起行时进入新批次,只剩展开行时启动它自己的过渡。因此不会出现「批次之外有行在变化、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次按每个成员的当前尺寸接管:收起行从现有高度收缩,展开行从它自己的过渡已达到的高度继续增长,只有剩余的增长量计入落点;已无尺寸的收起行直接完成、不做动画。 +- 只含展开行的批次不需要补白,仍用 CSS 过渡。viewport 未挂载阅读策略时,收起行也用 CSS 过渡、被挂起的行立即完成;减少动态效果时收起行立即完成。 +- 收起行先提交 hidden,驱动器再安装最终几何,之后才通知 fold-idle 监听者(例如切换展示模式后清空补白)。 +- 通用路径已删除:`reserve` 回调、`pendingReserve` 与 `pendingGrowth` 的预留计算、`reserveBelow`、折叠结束后再跟随的 `followAfterFold`。保留 `reclaimBelow`(新内容到来或读者滚动后归还补白)与 `resetBelow`(切换展示模式后清空补白)。 + +### 落点 + +记 `start` 为批次开始时的 scrollTop,`above` 为视口顶部以上收起行的高度减去展开行的高度,`base = max(0, start − above)`,`bottom` 为折叠后的底部位置。 + +| 落点 | 何时 | 目标 scrollTop | 结束后 | +|---|---|---|---| +| **到底部** | 本客户端的自己输入,或批次开始时读者正在跟随底部、且没有尚待采样的阅读滚动 | `max(base, bottom)` | 提交跟随状态 | +| **读者内容不动** | 其余情况 | `base` | 保持读者的阅读状态,确认新位置 | + +- 两种落点都只安装精确差额作为补白:`max(0, 目标 + 视口高 − 折叠后高度)`。到底部落点在结束时读取已提交的实际排版:落在目标与真实底部中较大的位置,只保留该目标所需的空间。 +- 「内容只上不下」:已经在视口顶部以上的内容不会回滑。保留的正文和新消息不足一屏时留出差额,新内容到来后填满。 +- 批次进行中出现自己输入时,从当前位置开新段,改为到底部;即使阅读手势已取消自动目标,也由新输入接管。只有批次正在运行或已排定时才接受到底部请求;批次结束后仍在用 CSS 过渡收起的行,不接管自己的输入,交给普通跟随。 +- 读者的滚动手势会取消「到底部」的自动目标,保留读者位置。「读者内容不动」时,帧间读者自己的滚动会并入批次保持的位置。 +- 「读者内容不动」时,驱动器对有序的顶层行二分查找,选取视口里第一行不属于本批次的可见顶层行作为参照。批次开始和收起行提交 hidden 时,相邻行的间距会瞬间改变;驱动器在这两处测量参照行的位移,抵消到 scrollTop 上。 +- 「到底部」且需要向前滚过视口顶部以下的收起行时,这些行保持不透明、随内容滚动,在收缩中裁剪显示,嵌套的收起行在批次结束前保持原尺寸。其余收起行原地淡出。新的到底部请求接管运行中的批次时,会把仍在视口顶部以下的收起行加入随行;已经淡出的行会以剩余高度重新变为不透明。这样向前滚动不会穿过一片已经透明、还没收完的空位。 + +### 自己输入的判定 + +| 来源 | 判定 | 落点 | +|---|---|---| +| 主会话输入框空闲发送 | 对话流回显 | 到底部 | +| 子会话输入框空闲发送 | 子会话同样注册本地回显;`subagents.prompt` 使用回显的 `requestId` | 到底部 | +| 运行中排队、上一轮结束后自动投递 | 不是自己输入 | 跟随底部时到底部,否则内容不动 | +| 另一个标签页发送 | 不是自己输入 | 同上 | +| 父 agent 给子会话发消息 | 通知不开启交互段,不折叠 | — | + +「完成时收起」不在动画作用域内,折叠立即生效,不受影响。 + +## Testing + +[fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts) 覆盖主会话 19 例与右侧栏子会话 8 例,维度包括读者位置(底部、正文中段、正文开头、步骤中间、上一轮)、输入来源(输入框、回合在写入消息前失败、另一个标签页、排队投递、steer、父消息、一次折叠多轮)、正文长短,以及 Standard 与「完成时收起」对照。布局空洞指视口顶部到输入框之间没有任何可见行的盒子覆盖的最大连续高度:落定后不超过 120 px,动画中至多 1 帧超过。它按盒子计算,已经淡出但尚未收完的收起行仍算作覆盖。「到底部」的用例断言落到底部;「内容不动」的用例断言读者锚定行的屏幕位置变化不超过 2 px。正文很短的用例只断言回复到达后不再留空。 + +## Alternatives considered + +**保留通用路径,只修自己输入的识别。** 两条路径的几何规则继续分叉;非自己输入时,中段读者要么被拉到底部,要么仍然看到空白。 + +**通用路径改为按读者位置补偿,但与输入路径分开实现。** 补偿与补白的几何代码重复一份,两边容易再次不一致。 + +**用开启交互段的人类输入节点识别自己输入,子会话不补回显。** 另一个标签页的输入会被当成自己输入,把中段读者拉到底部;子会话发送后仍然不能立即看到自己的消息。 + +**允许内容回滑,以填满短正文留下的差额。** 同一次动画里内容先上后下。 + +## Consequences + +- 收益:整屏空白的来源(通用路径)不再存在;落点只看「是不是自己的输入」和「读者是否在跟随」,与消息从哪个页面、何时投递无关。子会话发送立即显示自己的消息。 +- 代价:正文很短时,「内容只上不下」仍会留出差额,直到新内容填满。 +- 焦点停在正在收起的行里时,焦点保护会让该行保持展开。若它是被封顶高度的过程组,正文会立即缩到封顶高度。这次缩小发生在批次开始之前,「读者内容不动」的落点测不到,读者内容会移动一次。 diff --git a/apps/web/tests/fold-blank-matrix.e2e.ts b/apps/web/tests/fold-blank-matrix.e2e.ts new file mode 100644 index 0000000000..3422c15238 --- /dev/null +++ b/apps/web/tests/fold-blank-matrix.e2e.ts @@ -0,0 +1,875 @@ +/** + * Blank-area matrix for "On next message" folding. A human message folds the + * previous exchange's work details; each case records the largest band between + * the scrollport top and the composer that no visible row covers, after the fold + * settles, after the Turn the message started settles, and on every animation + * frame in between. Cases cover the main Session and a subagent child Session + * hosted in the right Sidebar, across reader positions and input origins. + */ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page, Route } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import { ToolCallId, createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay' +import { + SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq, type SessionEvent, type SessionHeader, +} from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { sessionFixtureName } from '@deepseek-ai/dsh-session-snapshot' +import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts' +import { launchWebScaffold, parseSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { newEnglishPage, openSettings } from './support.ts' + +declare global { + interface Window { + foldProbe?: { readonly samples: Geometry[]; stop(): void } + foldMeasure?: (root: string) => Geometry + } +} + +const MODE = webSnapshotMode() +const SEEDED_TURNS = 8 +const SEEDED_STEPS = 6 +const LIVE_STEPS = 6 +/** Live Steps the steered Turn replays before the steering message is delivered. */ +const STEER_STEPS = 3 +const BLANK_LIMIT = 120 +/** + * Frames whose layout gap may exceed {@link BLANK_LIMIT}. The gap counts rows by their boxes, so a + * closing row that has faded but not finished shrinking still covers its space. + */ +const TRANSIENT_FRAMES = 1 +const EVIDENCE = fileURLToPath(new URL('../../../.artifacts/screenshots/fold-blank-matrix/', import.meta.url)) +const MAIN_ROOT = '[data-conversation-scroll]:not([data-sidebar-chat] [data-conversation-scroll])' +const SIDE_ROOT = '[data-sidebar-chat] [data-conversation-scroll]' +const CHILD_LABEL = 'fold matrix child' +const READ_FILE = 'fold-matrix-notes.txt' +const NEXT_INPUT = 'FOLDMX_NEXT_INPUT' + +/** Where the reader stands when the message is sent. */ +type Reader = + | { readonly kind: 'bottom' } + | { readonly kind: 'marker'; readonly marker: string; readonly align: 'center' | 'top' } + +const AT_BOTTOM: Reader = { kind: 'bottom' } +const SEEDED_MID_BODY: Reader = { kind: 'marker', marker: 'FOLDMX_BODY', align: 'center' } +const SEEDED_BODY_TOP: Reader = { kind: 'marker', marker: 'FOLDMX_BODY', align: 'top' } +const SEEDED_STEPS_READER: Reader = { kind: 'marker', marker: 'FOLDMX_STEP_4', align: 'top' } +const PREVIOUS_TURN: Reader = { kind: 'marker', marker: `FOLDMX_PREVIOUS_${SEEDED_TURNS - 1}`, align: 'top' } +const LIVE_MID_BODY: Reader = { kind: 'marker', marker: 'FOLDMX_LIVE_BODY', align: 'center' } + +/** + * How the folding message arrives. + * - `composer`: this page's composer sends while the Session is idle. + * - `error`: as `composer`, but the Turn fails before its user message is recorded. + * - `other-tab`: a second page sends; this page receives it without a local echo. + * - `queued`: this page queues the message while a long live Turn runs; it is delivered after that Turn ends. + * - `steer`: as `queued`, but the queued message is steered into the running Turn. + * - `parent-message`: the live parent Agent messages the child (side chat only). + * - `big-exchange`: two parent messages wake long child Turns before the human message (side chat only). + */ +type Origin = 'composer' | 'error' | 'other-tab' | 'queued' | 'steer' | 'parent-message' | 'big-exchange' + +interface Case { + readonly id: string + readonly surface: 'main' | 'side' + readonly origin: Origin + readonly reader: Reader + readonly bodyParagraphs: number + readonly mode: 'Detailed' | 'Standard' + readonly timing: 'On next message' | 'On completion' + /** + * `tail`: the view ends at the floor. `reader`: the reader's anchor row keeps its screen position. + */ + readonly landing: 'tail' | 'reader' + /** The reader's anchor row is itself folded, so only the blank area is asserted. */ + readonly anchorFolds?: true + /** Content above the viewport never slides back down, so a short body leaves room until newer content fills it. */ + readonly roomUntilReply?: true +} + +const MAIN = { + surface: 'main', origin: 'composer', bodyParagraphs: 40, mode: 'Detailed', timing: 'On next message', landing: 'tail', +} as const +const SIDE = { ...MAIN, surface: 'side' } as const + +const CASES: readonly Case[] = [ + { ...MAIN, id: 'main/composer/bottom', reader: AT_BOTTOM }, + { ...MAIN, id: 'main/composer/mid-body', reader: SEEDED_MID_BODY }, + { ...MAIN, id: 'main/composer/body-top', reader: SEEDED_BODY_TOP }, + { ...MAIN, id: 'main/composer/steps', reader: SEEDED_STEPS_READER }, + { ...MAIN, id: 'main/composer/previous-turn', reader: PREVIOUS_TURN }, + { ...MAIN, id: 'main/composer/short-body/bottom', reader: AT_BOTTOM, bodyParagraphs: 2, roomUntilReply: true }, + { ...MAIN, id: 'main/composer/short-body/steps', reader: SEEDED_STEPS_READER, bodyParagraphs: 2, roomUntilReply: true }, + { ...MAIN, id: 'main/composer/short-body/previous-turn', reader: PREVIOUS_TURN, bodyParagraphs: 2 }, + { ...MAIN, id: 'main/error-before-message/bottom', origin: 'error', reader: AT_BOTTOM }, + { ...MAIN, id: 'main/error-before-message/mid-body', origin: 'error', reader: SEEDED_MID_BODY }, + { ...MAIN, id: 'main/other-tab/bottom', origin: 'other-tab', reader: AT_BOTTOM }, + { ...MAIN, id: 'main/other-tab/mid-body', origin: 'other-tab', reader: SEEDED_MID_BODY, landing: 'reader' }, + { ...MAIN, id: 'main/other-tab/steps', origin: 'other-tab', reader: SEEDED_STEPS_READER, landing: 'reader', anchorFolds: true }, + { ...MAIN, id: 'main/other-tab/previous-turn', origin: 'other-tab', reader: PREVIOUS_TURN, landing: 'reader' }, + { ...MAIN, id: 'main/queued/bottom', origin: 'queued', reader: AT_BOTTOM }, + { ...MAIN, id: 'main/queued/mid-body', origin: 'queued', reader: LIVE_MID_BODY, landing: 'reader' }, + // Control: steering joins the running Turn, which opens no exchange and folds nothing. + { ...MAIN, id: 'main/steer/no-fold-control', origin: 'steer', reader: AT_BOTTOM }, + { ...MAIN, id: 'main/composer/standard/mid-body', reader: SEEDED_MID_BODY, mode: 'Standard' }, + { ...MAIN, id: 'main/composer/completion/mid-body', reader: SEEDED_MID_BODY, timing: 'On completion' }, + { ...SIDE, id: 'side/composer/bottom', reader: AT_BOTTOM }, + { ...SIDE, id: 'side/composer/mid-body', reader: SEEDED_MID_BODY }, + { ...SIDE, id: 'side/composer/steps', reader: SEEDED_STEPS_READER }, + { ...SIDE, id: 'side/composer/previous-turn', reader: PREVIOUS_TURN }, + { ...SIDE, id: 'side/queued/bottom', origin: 'queued', reader: AT_BOTTOM }, + { ...SIDE, id: 'side/queued/mid-body', origin: 'queued', reader: LIVE_MID_BODY, landing: 'reader' }, + // Control: a parent message is a notice, which opens no exchange and folds nothing. + { ...SIDE, id: 'side/parent-message/no-fold-control', origin: 'parent-message', reader: SEEDED_MID_BODY, landing: 'reader' }, + { ...SIDE, id: 'side/big-exchange/bottom', origin: 'big-exchange', reader: AT_BOTTOM }, +] + +interface Geometry { + readonly top: number + readonly floor: number + readonly spacer: number + /** Largest band between the scrollport top and the composer that no visible row covers. */ + readonly maxGap: number + readonly gapAt: number + /** Empty height between the last visible row and the composer. */ + readonly tailGap: number + readonly following: boolean + /** A row was closing on this sample. */ + readonly collapsing: boolean +} + +/** Installed into each measured page before load; reads one scrollport's blank-area geometry. */ +function installMeasure(): void { + window.foldMeasure = (root) => { + const host = document.querySelector(root) + if (host === null) throw new Error(`scrollport is absent: ${root}`) + const viewport = host.getBoundingClientRect() + const composer = host.querySelector('[data-composer-seat]') + if (composer === null) throw new Error(`composer is absent: ${root}`) + const composerTop = composer.getBoundingClientRect().top + const spacer = host.querySelector('[data-chat-turn-spacer]')?.getBoundingClientRect().height ?? 0 + const intervals = [...host.querySelectorAll('[data-chat-flow-key], [data-submission-echo], [data-pending-steering], [data-streaming], [data-chat-running]')] + .filter(element => element.closest('[hidden]') === null) + .map((element) => { + const rect = element.getBoundingClientRect() + return [Math.max(rect.top, viewport.top), Math.min(rect.bottom, composerTop)] as const + }) + .filter(([top, bottom]) => bottom - top > 0.5) + .sort((left, right) => left[0] - right[0]) + let cursor = viewport.top + let maxGap = 0 + let gapAt = 0 + for (const [top, bottom] of intervals) { + if (top - cursor > maxGap) { + maxGap = top - cursor + gapAt = cursor - viewport.top + } + cursor = Math.max(cursor, bottom) + } + if (composerTop - cursor > maxGap) { + maxGap = composerTop - cursor + gapAt = cursor - viewport.top + } + return { + top: host.scrollTop, floor: Math.max(0, host.scrollHeight - host.clientHeight), spacer, + maxGap, gapAt, tailGap: composerTop - cursor, + following: host.querySelector('[data-chat-following-tail]') !== null, + collapsing: host.querySelector('[data-chat-motion="collapse"]') !== null, + } + } +} + +function paragraphs(marker: string, count: number, text: string): string { + return `${marker}\n\n${Array.from({ length: count }, (_, line) => `${text} ${line}.`).join('\n\n')}` +} + +/** Main-session log: settled Turns, then a long last Turn of expanded reasoning Steps and a final body. */ +function seededFixture(id: string, bodyParagraphs: number): ChatScrollFixture { + const base = createChatScrollFixture({ markerPrefix: 'FOLDMX', title: `FOLDMX ${id}`, turns: SEEDED_TURNS }) + const parsed = parseSeedFixture(base.log) + const boundary = parsed.events.findIndex(event => event.type === 'turn/start' && event.data.turn === SEEDED_TURNS) + const drafts: (SessionEvent | { type: string; data: object; surfaceOp?: 'append' })[] = parsed.events.slice(0, boundary) + .map((event) => { + if (event.type !== 'assistant/message' || event.data.turn !== SEEDED_TURNS - 1) return event + return { ...event, data: { ...event.data, message: createAssistantMessage({ + content: [{ type: 'text', text: paragraphs(`FOLDMX_PREVIOUS_${SEEDED_TURNS - 1}`, 6, 'Previous Turn answer paragraph') }], + source: { provider: 'fixture', model: 'fixture' }, + }) } } + }) + drafts.push( + { type: 'turn/start', data: { turn: SEEDED_TURNS } }, + { type: 'step/start', data: { turn: SEEDED_TURNS, step: 1 } }, + { type: 'user/message', data: createUserMessage({ + content: [{ type: 'text', text: 'FOLDMX_LAST_USER' }], source: { kind: 'user' }, + }), surfaceOp: 'append' }, + ) + for (let step = 1; step <= SEEDED_STEPS; step++) { + if (step > 1) drafts.push({ type: 'step/start', data: { turn: SEEDED_TURNS, step } }) + drafts.push({ type: 'assistant/message', surfaceOp: 'append', data: { + turn: SEEDED_TURNS, step, stream: [], message: createAssistantMessage({ + content: [ + { type: 'reasoning', text: paragraphs(`FOLDMX_STEP_${step}`, 18, `Reasoning stage ${step} explains an intermediate decision, paragraph`) }, + { type: 'text', text: `FOLDMX_PROGRESS_${step}` }, + ], + source: { provider: 'fixture', model: 'fixture' }, + }), + } }) + drafts.push({ type: 'step/end', data: { turn: SEEDED_TURNS, step } }) + } + drafts.push( + { type: 'step/start', data: { turn: SEEDED_TURNS, step: SEEDED_STEPS + 1 } }, + { type: 'assistant/message', surfaceOp: 'append', data: { + turn: SEEDED_TURNS, step: SEEDED_STEPS + 1, stream: [], message: createAssistantMessage({ + content: [{ type: 'text', text: paragraphs('FOLDMX_BODY', bodyParagraphs, 'Final answer paragraph that remains after the work details fold') }], + source: { provider: 'fixture', model: 'fixture' }, + }), + } }, + { type: 'step/end', data: { turn: SEEDED_TURNS, step: SEEDED_STEPS + 1 } }, + { type: 'turn/end', data: { turn: SEEDED_TURNS, reason: { kind: 'completed' } } }, + ) + return { ...base, log: [parsed.headerLine, + ...drafts.map((event, seq) => JSON.stringify({ ...event, seq, time: seq + 1 })), ''].join('\n') } +} + +function finish(kind: 'stop' | 'tool-calls'): StreamChunk[] { + return [ + { type: 'usage', usage: { inputTokens: 512, outputTokens: 64 } }, + { type: 'finish', reason: { kind } }, + ] +} + +function textChunks(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + ] +} + +function replyEntry(marker: string): ReplayEntry { + return { kind: 'chunks', chunks: [...textChunks(`${marker} short answer.`), ...finish('stop')] } +} + +/** One live Turn: Steps of narration plus a workspace read, then a long final body. */ +function liveLongTurn(prefix: string): ReplayEntry[] { + const entries: ReplayEntry[] = [] + for (let step = 1; step <= LIVE_STEPS; step++) { + const id = ToolCallId(`${prefix.toLowerCase()}-read-${step}`) + const args = JSON.stringify({ file_path: READ_FILE }) + entries.push({ kind: 'chunks', chunks: [ + ...textChunks(paragraphs(`${prefix}_STEP_${step}`, 10, `${prefix} narration for step ${step}, paragraph`)), + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 1, id, name: 'read', argumentsDelta: args }, + { type: 'block-end', index: 1, block: { type: 'tool-call', id, name: 'read', arguments: args } }, + ...finish('tool-calls'), + ] }) + } + entries.push({ kind: 'chunks', chunks: [ + ...textChunks(paragraphs(prefix === 'FOLDMX_LIVE' ? 'FOLDMX_LIVE_BODY' : `${prefix}_BODY`, 40, `${prefix} final answer paragraph`)), + ...finish('stop'), + ] }) + return entries +} + +function replayFor(scenario: Case): ReplayEntry[] { + switch (scenario.origin) { + case 'composer': + case 'other-tab': + return [replyEntry('FOLDMX_REPLY')] + case 'queued': + return [...liveLongTurn('FOLDMX_LIVE'), replyEntry('FOLDMX_REPLY')] + case 'steer': + return [...liveLongTurn('FOLDMX_LIVE').slice(0, STEER_STEPS), replyEntry('FOLDMX_STEERED')] + case 'parent-message': + return [replyEntry('FOLDMX_PARENT_REPLY')] + case 'error': + // The Turn fails before its model call; launchScaffold mounts only the provider catalog. + return [] + case 'big-exchange': + return [...liveLongTurn('FOLDMX_WAKE_A'), ...liveLongTurn('FOLDMX_WAKE_B'), replyEntry('FOLDMX_REPLY')] + } +} + +async function stableScroll(page: Page, root: string, timeoutMs = 20_000): Promise { + await page.evaluate(async ([selector, limit]) => { + const host = document.querySelector(selector) + if (host === null) throw new Error(`scrollport is absent: ${selector}`) + let previous = host.scrollTop + const started = performance.now() + let stableSince = started + await new Promise((resolveStable, reject) => { + const check = (): void => { + const now = performance.now() + const moving = host.querySelector('[data-chat-motion="collapse"], [data-chat-motion="reveal"]') !== null + if (Math.abs(host.scrollTop - previous) > 0.25 || moving) stableSince = now + previous = host.scrollTop + if (now - stableSince >= 300) resolveStable() + else if (now - started >= limit) reject(new Error(`scroll did not settle within ${String(limit)}ms: top ${String(host.scrollTop)}, motion ${String(moving)}`)) + else requestAnimationFrame(check) + } + requestAnimationFrame(check) + }) + }, [root, timeoutMs] as const) +} + +function measure(page: Page, root: string): Promise { + return page.evaluate((selector) => { + if (window.foldMeasure === undefined) throw new Error('fold measurement is not installed') + return window.foldMeasure(selector) + }, root) +} + +async function startTrace(page: Page, root: string): Promise { + await page.evaluate((selector) => { + const read = window.foldMeasure + if (read === undefined) throw new Error('fold measurement is not installed') + const samples: Geometry[] = [] + let frame = 0 + const tick = (): void => { + // A scrollport can remount between frames; that frame contributes no sample. + try { samples.push(read(selector)) } catch (_remounting) { /* skip this frame */ } + frame = requestAnimationFrame(tick) + } + window.foldProbe = { samples, stop: () => { cancelAnimationFrame(frame) } } + frame = requestAnimationFrame(tick) + }, root) +} + +async function stopTrace(page: Page): Promise { + return page.evaluate(() => { + const probe = window.foldProbe + if (probe === undefined) throw new Error('fold trace was not started') + probe.stop() + return probe.samples + }) +} + +/** Screen position of the smallest visible row containing a marker, relative to the scrollport top. */ +function anchorTop(page: Page, root: string, marker: string): Promise { + return page.evaluate(([selector, text]) => { + const host = document.querySelector(selector)! + const row = [...host.querySelectorAll('[data-chat-flow-key], [data-streaming]')] + .filter(candidate => candidate.closest('[hidden]') === null && (candidate.textContent ?? '').includes(text)) + .sort((left, right) => left.getBoundingClientRect().height - right.getBoundingClientRect().height)[0] + return row === undefined ? null : row.getBoundingClientRect().top - host.getBoundingClientRect().top + }, [root, marker] as const) +} + +async function toBottom(page: Page, root: string): Promise { + const button = page.locator(root).getByRole('button', { name: 'Back to bottom', exact: true }) + if (await button.count() > 0) await button.click() + await stableScroll(page, root) + await expect.poll(() => page.locator(`${root} [data-chat-following-tail]`).count()).toBe(1) +} + +async function placeReader(page: Page, root: string, reader: Reader): Promise { + if (reader.kind === 'bottom') { + await toBottom(page, root) + return + } + const delta = await page.evaluate(([selector, marker, align]) => { + const host = document.querySelector(selector)! + const viewport = host.getBoundingClientRect() + const composerTop = host.querySelector('[data-composer-seat]')!.getBoundingClientRect().top + const rows = [...host.querySelectorAll('[data-chat-flow-key], [data-streaming]')] + .filter(row => row.closest('[hidden]') === null && (row.textContent ?? '').includes(marker)) + .sort((left, right) => left.getBoundingClientRect().height - right.getBoundingClientRect().height) + const row = rows[0] + if (row === undefined) throw new Error(`reader anchor is absent: ${marker}`) + const rect = row.getBoundingClientRect() + return align === 'center' + ? rect.top + rect.height / 2 - (viewport.top + composerTop) / 2 + : rect.top - viewport.top - 24 + }, [root, reader.marker, reader.align] as const) + const box = await page.locator(root).boundingBox() + if (box === null) throw new Error('scrollport has no layout box') + await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3)) + await page.mouse.wheel(0, delta) + await stableScroll(page, root) + // Reader movement is sampled on scrollend or after the sampling interval. + await page.waitForTimeout(700) + expect(await page.locator(`${root} [data-chat-following-tail]`).count()).toBe(0) +} + +async function openSessionBySearch(page: Page, query: string): Promise { + await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 }) + const searchButton = page.getByRole('button', { name: 'Search sessions' }) + if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click() + await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(query) + const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1) + await results.click() +} + +async function choosePreferences(page: Page, scenario: Case): Promise { + await openSettings(page, 'en') + const dialog = page.getByRole('dialog', { name: 'Settings', exact: true }) + const timing = dialog.getByText('When to Collapse Work Details', { exact: true }).locator('../..') + await timing.getByRole('button').click() + await page.getByRole('menuitem', { name: scenario.timing, exact: true }).click() + const details = dialog.getByText('Work details', { exact: true }).locator('../..') + await details.getByRole('button').click() + await page.getByRole('menuitem', { name: scenario.mode, exact: true }).click() + await dialog.getByRole('button', { name: 'Close', exact: true }).click() +} + +async function expandSeededSteps(page: Page, root: string, scenario: Case): Promise { + if (scenario.mode !== 'Detailed' || scenario.timing !== 'On next message') return + const thoughts = page.locator(`${root} [data-chat-turn="${SEEDED_TURNS}"] [data-variant="think"]`) + await expect.poll(() => thoughts.count()).toBe(SEEDED_STEPS) + for (let index = 0; index < SEEDED_STEPS; index++) { + const button = thoughts.nth(index).getByRole('button').first() + if (await button.getAttribute('aria-expanded') !== 'true') await button.click() + } + // Focus left inside a folding row keeps that row open; a reader who moved on has focused nothing there. + await page.evaluate(() => { if (document.activeElement instanceof HTMLElement) document.activeElement.blur() }) +} + +/** Model-call barrier for one live Turn. */ +interface FinishHold { + /** Resolves when the held call streams its finish; rejects after the timeout. */ + reached(timeoutMs: number): Promise + release(): void + dispose(): void + /** Model calls observed since installation and the target Session's latest event types. */ + describe(): string +} + +/** + * Hold the finish of the first model call for `session` whose streamed text contains `marker`. + * The call is identified by its replayed content, so auxiliary or reordered calls cannot shift the barrier. + */ +function holdFinishOf(scaffold: WebScaffold, session: SessionId, marker: string): FinishHold { + const gate = Promise.withResolvers() + const reached = Promise.withResolvers() + const calls: string[] = [] + let held = false + const dispose = scaffold.ctx.on('llm/stream', async function* (options, next) { + const index = calls.length + calls.push(`#${String(index)} ${options.sessionId ?? 'no-session'}/${options.purpose ?? 'turn'}`) + let matched = false + for await (const chunk of next()) { + if (chunk.type === 'block-end' && chunk.block.type === 'text' && chunk.block.text.includes(marker)) matched = true + if (chunk.type === 'finish') { + calls[index] = `${calls[index] ?? ''} finish=${chunk.reason.kind}${matched ? ` ${marker}` : ''}` + if (!held && matched && options.sessionId === session) { + held = true + reached.resolve(undefined) + await gate.promise + } + } + yield chunk + } + }) + const describe = (): string => { + const events = scaffold.ctx.agents.get(session)?.session.snapshotEvents() ?? [] + return `model calls: [${calls.join('; ')}]; ${session} event tail: ${events.slice(-12).map(event => event.type).join(', ')}` + } + return { + reached: async (timeoutMs) => { + let timer: ReturnType | undefined + const expired = new Promise((_resolve, reject) => { + timer = setTimeout(() => { reject(new Error(`no model call streamed ${marker} within ${String(timeoutMs)}ms`)) }, timeoutMs) + }) + try { + await Promise.race([reached.promise, expired]) + } finally { + clearTimeout(timer) + } + }, + release: () => { gate.resolve(undefined) }, + dispose: () => { gate.resolve(undefined); dispose() }, + describe, + } +} + +interface World { + readonly browser: Browser + readonly scaffold: WebScaffold + readonly page: Page + readonly root: string + /** Session receiving the folding message. */ + readonly target: SessionId + /** Live parent Agent for a side-chat case. */ + readonly parentId?: SessionId + readonly tripwire: ReturnType + /** Session-search text that selects the target's root Session. */ + readonly searchQuery: string +} + +function sessionKey(scenario: Case): string { + return `foldmx-${scenario.id.replaceAll('/', '-')}` +} + +async function createChild(scaffold: WebScaffold, parentId: SessionId, scenario: Case): Promise { + const childId = SessionId(`${sessionKey(scenario)}-child`) + const createdAt = Date.now() - 30_000 + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, id: childId, createdAt, isSeeded: false, cwd: scaffold.workspaceCwd, + parentSession: parentId, origin: 'subagent', delegationDepth: 1, + } + const parsed = parseSeedFixture(seededFixture(scenario.id, scenario.bodyParagraphs).log) + const firstUser = parsed.events.findIndex(event => event.type === 'user/message') + const ordered = [ + ...parsed.events.slice(0, firstUser + 1), + { type: 'subagent/descriptor', data: snapshotSubagentDescriptor({ + mode: 'continuable', provider: 'spawn', label: CHILD_LABEL, agentProvider: 'deepseek-official', agentModel: 'deepseek-v4-flash', + }) }, + ...parsed.events.slice(firstUser + 1), + ] + const events = ordered.map((event, seq) => ({ ...event, seq: SessionSeq(seq), time: createdAt + seq })) as SessionEvent[] + const handle = await scaffold.ctx.sessionPersistence.create(header) + await handle.append(events) + await handle.close() + scaffold.ctx.sessionProjectionCache.coldSnapshot(header, SessionLogOffset(0), events) + const deadline = Date.now() + 10_000 + while (scaffold.ctx.sessionProjectionCache.cachedSnapshot(header) === undefined) { + if (Date.now() >= deadline) throw new Error('child projection cache row did not land') + await new Promise(resolve => setTimeout(resolve, 10)) + } + const parent = scaffold.ctx.agents.get(parentId) + if (parent === undefined) throw new Error('parent Agent is not live') + parent.session.append('subagent/catalog', { version: 0, childId, childCreatedAt: createdAt, mode: 'continuable', label: CHILD_LABEL }) + return childId +} + +async function openWorld(browser: Browser, scenario: Case, replayDir: string): Promise { + const scaffold = await launchScaffold(scenario, replayDir) + let page: Page | undefined + try { + page = await newEnglishPage(browser, 900) + return await prepareWorld(browser, scenario, scaffold, page) + } catch (error) { + const failures: unknown[] = [error] + if (page !== undefined) await page.context().close().catch((cleanupError: unknown) => failures.push(cleanupError)) + await scaffold.close().catch((cleanupError: unknown) => failures.push(cleanupError)) + if (failures.length === 1) throw error + throw new AggregateError(failures, `${scenario.id}: world setup failed and cleanup was incomplete`) + } +} + +/** + * Launch the scaffold with the case's replay script. A Turn that fails before its model call consumes + * no script, so that case mounts only the provider catalog from a header-only fixture. + */ +async function launchScaffold(scenario: Case, replayDir: string): Promise { + if (scenario.origin === 'error') { + const header = seededFixture(scenario.id, scenario.bodyParagraphs).log.split('\n')[0] ?? '' + const fixture = join(replayDir, sessionFixtureName(0, SESSION_FORMAT_VERSION)) + await writeFile(fixture, `${header}\n`) + return launchWebScaffold({ replayFixture: fixture, replayProvidersOnly: true }) + } + await writeFile(join(replayDir, 'replay.override.json'), JSON.stringify(replayFor(scenario))) + return launchWebScaffold({ + replayFixture: join(replayDir, 'override-only.jsonl'), + replayOverride: join(replayDir, 'replay.override.json'), + paceMs: 16, + replayContextWindow: 10_000_000, + }) +} + +/** Seed the case's Sessions, open the surface, and choose the display preferences. */ +async function prepareWorld(browser: Browser, scenario: Case, scaffold: WebScaffold, page: Page): Promise { + await writeFile(join(scaffold.workspaceCwd, READ_FILE), 'fold matrix notes\n') + const tripwire = watchConsole(page) + await page.addInitScript(installMeasure) + await page.setViewportSize(scenario.surface === 'main' ? { width: 1280, height: 720 } : { width: 1680, height: 900 }) + await page.emulateMedia({ reducedMotion: 'no-preference' }) + const sessionId = SessionId(sessionKey(scenario)) + if (scenario.surface === 'main') { + const fixture = seededFixture(scenario.id, scenario.bodyParagraphs) + await seedSession(scaffold, fixture.log, sessionId) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSessionBySearch(page, fixture.markers.user(1)) + await page.locator(MAIN_ROOT).getByText('FOLDMX_BODY', { exact: false }).last().waitFor({ timeout: 30_000 }) + const resolved = await scaffold.ctx.sessionController.resolveAgent(sessionId) + if ('error' in resolved) throw resolved.error + scaffold.ctx.permissionPresets.set(resolved.agent.session, 'danger-full-access') + await choosePreferences(page, scenario) + return { browser, scaffold, page, root: MAIN_ROOT, target: sessionId, tripwire, searchQuery: fixture.markers.user(1) } + } + const parentFixture = createChatScrollFixture({ markerPrefix: 'FOLDMXP', title: `FOLDMX parent ${scenario.id}`, turns: 2 }) + await seedSession(scaffold, parentFixture.log, sessionId) + const resolved = await scaffold.ctx.sessionController.resolveAgent(sessionId) + if ('error' in resolved) throw resolved.error + scaffold.ctx.permissionPresets.set(resolved.agent.session, 'danger-full-access') + const childId = await createChild(scaffold, sessionId, scenario) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSessionBySearch(page, parentFixture.markers.user(1)) + await choosePreferences(page, scenario) + const trigger = page.getByRole('button', { name: '1 subagent', exact: true }) + await trigger.waitFor({ timeout: 15_000 }) + await trigger.hover() + await page.getByRole('treeitem', { name: new RegExp(CHILD_LABEL) }) + .getByRole('button', { name: `Open ${CHILD_LABEL} in sidebar`, exact: true }).click() + await page.locator(SIDE_ROOT).getByText('FOLDMX_BODY', { exact: false }).last().waitFor({ timeout: 30_000 }) + await page.mouse.move(0, 0) + return { + browser, scaffold, page, root: SIDE_ROOT, target: childId, parentId: sessionId, tripwire, + searchQuery: parentFixture.markers.user(1), + } +} + +function composerOf(world: World): ReturnType { + return world.page.locator(`${world.root} [data-composer-input][contenteditable="true"]`).last() +} + +async function send(world: World, text: string): Promise { + await composerOf(world).fill(text) + await world.page.locator(world.root).getByRole('button', { name: 'Send message', exact: true }).click() +} + +/** Wait until a Session's Agent ends a Turn. */ +function turnEnded(scaffold: WebScaffold, sessionId: SessionId, count = 1, timeoutMs = 60_000): Promise { + return new Promise((resolveEnded, reject) => { + let remaining = count + const timer = setTimeout(() => { off(); reject(new Error(`no turn/end for ${sessionId} within ${timeoutMs}ms`)) }, timeoutMs) + const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => { + if (session.id !== sessionId || event.type !== 'turn/end') return + remaining -= 1 + if (remaining > 0) return + clearTimeout(timer) + off() + resolveEnded() + }) + }) +} + +async function settle(world: World): Promise { + await expect.poll(() => world.page.locator(`${world.root} [data-streaming="true"]`).count(), { timeout: 30_000 }).toBe(0) + await stableScroll(world.page, world.root) + await world.page.waitForTimeout(900) + await stableScroll(world.page, world.root) +} + +/** Drive one case from its prepared reader position through the folding message and its Turn. */ +interface RunResult { + readonly afterFold: Geometry + readonly afterTurn: Geometry + readonly samples: Geometry[] + /** Reader anchor row positions when sending, after the fold, and after the Turn. */ + readonly anchors: { before: number | null; fold: number | null; turn: number | null } +} + +async function run(world: World, scenario: Case): Promise { + const { page, root, scaffold, target } = world + const anchors: RunResult['anchors'] = { before: null, fold: null, turn: null } + const readAnchor = (): Promise => scenario.reader.kind === 'marker' + ? anchorTop(page, root, scenario.reader.marker) : Promise.resolve(null) + const begin = async (): Promise => { + anchors.before = await readAnchor() + await startTrace(page, root) + } + const folded = async (): Promise => { + await stableScroll(page, root) + anchors.fold = await readAnchor() + return measure(page, root) + } + let afterFold: Geometry + switch (scenario.origin) { + case 'composer': + case 'error': { + const disposeFailure = scenario.origin === 'error' + ? scaffold.ctx.on('system-prompt/assemble', async () => { throw new Error('FOLDMX_TURN_FAILED') }) + : () => undefined + // A subagent child's composer sends through the subagent continuation route. + const promptRoute = scenario.surface === 'side' ? '**/api/subagents/prompt' : '**/api/session/prompt' + const promptGate = Promise.withResolvers() + const promptHeld = Promise.withResolvers() + const holdPrompt = async (route: Route): Promise => { + promptHeld.resolve(undefined) + await promptGate.promise + await route.continue() + } + await page.route(promptRoute, holdPrompt) + const ended = turnEnded(scaffold, target) + try { + await expandSeededSteps(page, root, scenario) + await placeReader(page, root, scenario.reader) + await begin() + await send(world, NEXT_INPUT) + await promptHeld.promise + await page.locator(`${root} [data-submission-echo]`).waitFor() + afterFold = await folded() + promptGate.resolve(undefined) + await ended + } finally { + promptGate.resolve(undefined) + disposeFailure() + await page.unroute(promptRoute, holdPrompt) + } + break + } + case 'other-tab': { + await expandSeededSteps(page, root, scenario) + const other = await newEnglishPage(world.browser, 720) + await other.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSessionBySearch(other, world.searchQuery) + await other.locator(MAIN_ROOT).getByText('FOLDMX_BODY', { exact: false }).last().waitFor({ timeout: 30_000 }) + await placeReader(page, root, scenario.reader) + await begin() + const ended = turnEnded(scaffold, target) + await other.locator(`${MAIN_ROOT} [data-composer-input][contenteditable="true"]`).last().fill(NEXT_INPUT) + await other.locator(MAIN_ROOT).getByRole('button', { name: 'Send message', exact: true }).click() + await page.locator(`${root} [data-chat-flow-kind="user"]`, { hasText: NEXT_INPUT }).waitFor() + afterFold = await folded() + await ended + await other.context().close() + break + } + case 'queued': + case 'steer': { + // Queued: hold the long final body. Steer: hold the last replayed Step so the steering message lands in this Turn. + const gate = holdFinishOf(scaffold, target, scenario.origin === 'queued' ? 'FOLDMX_LIVE_BODY' : `FOLDMX_LIVE_STEP_${String(STEER_STEPS)}`) + const first = turnEnded(scaffold, target, scenario.origin === 'queued' ? 2 : 1) + // A failed earlier step reports itself; the success path still awaits and rethrows this rejection. + first.catch(() => undefined) + try { + await toBottom(page, root) + await send(world, 'FOLDMX_START_LIVE_TURN') + await gate.reached(60_000) + await stableScroll(page, root) + if (scenario.origin === 'queued' && scenario.reader.kind === 'marker') { + await page.locator(root).getByText('FOLDMX_LIVE_BODY', { exact: false }).last().waitFor() + } + await placeReader(page, root, scenario.reader) + await composerOf(world).fill(NEXT_INPUT) + await composerOf(world).press('Enter') + const steerButton = page.getByRole('button', { name: 'Steer queued message' }) + await steerButton.waitFor({ timeout: 15_000 }) + if (scenario.origin === 'steer') { + await expect.poll(() => steerButton.isEnabled(), { timeout: 10_000 }).toBe(true) + await steerButton.click() + } + await begin() + gate.release() + await page.locator(`${root} [data-chat-flow-kind="user"], ${root} [data-chat-flow-kind="steering"]`, { hasText: NEXT_INPUT }) + .first().waitFor({ timeout: 30_000 }) + afterFold = await folded() + await first + } catch (error) { + throw new Error(`${scenario.id}: ${error instanceof Error ? error.message : String(error)}\n${gate.describe()}`, { cause: error }) + } finally { + gate.dispose() + } + break + } + case 'parent-message': { + await expandSeededSteps(page, root, scenario) + await placeReader(page, root, scenario.reader) + await begin() + const parent = scaffold.ctx.agents.get(world.parentId!) + if (parent === undefined) throw new Error('parent Agent is not live') + const ended = turnEnded(scaffold, target) + await scaffold.ctx.subagents.sendMessage(parent, target, [{ type: 'text', text: 'FOLDMX_PARENT_NOTE' }], { signal: new AbortController().signal }) + await page.locator(`${root} [data-chat-turn="${SEEDED_TURNS + 1}"]`).first().waitFor({ timeout: 30_000 }) + afterFold = await folded() + await ended + break + } + case 'big-exchange': { + const parent = scaffold.ctx.agents.get(world.parentId!) + if (parent === undefined) throw new Error('parent Agent is not live') + for (const note of ['FOLDMX_WAKE_A_NOTE', 'FOLDMX_WAKE_B_NOTE']) { + const woken = turnEnded(scaffold, target) + await scaffold.ctx.subagents.sendMessage(parent, target, [{ type: 'text', text: note }], { signal: new AbortController().signal }) + await woken + } + await settle(world) + await placeReader(page, root, scenario.reader) + await begin() + const ended = turnEnded(scaffold, target) + await send(world, NEXT_INPUT) + await page.locator(`${root} [data-chat-flow-kind="user"]`, { hasText: NEXT_INPUT }).waitFor({ timeout: 30_000 }) + afterFold = await folded() + await ended + break + } + } + await settle(world) + anchors.turn = await readAnchor() + const afterTurn = await measure(page, root) + const samples = await stopTrace(page) + return { afterFold, afterTurn, samples, anchors } +} + +function rounded(geometry: Geometry): Record { + return { + top: Math.round(geometry.top), floor: Math.round(geometry.floor), spacer: Math.round(geometry.spacer), + maxGap: Math.round(geometry.maxGap), gapAt: Math.round(geometry.gapAt), following: geometry.following, + } +} + +describe.skipIf(MODE === 'record')('web e2e: blank area after next-message folding', () => { + let browser: Browser + + beforeAll(async () => { + browser = await chromium.launch() + }) + + afterAll(async () => { + await browser?.close() + }) + + for (const scenario of CASES) { + it(scenario.id, async () => { + const replayDir = await mkdtemp(join(tmpdir(), 'dsh-fold-matrix-')) + let world: World | undefined + try { + world = await openWorld(browser, scenario, replayDir) + const { afterFold, afterTurn, samples, anchors } = await run(world, scenario) + const transient = samples.filter(sample => sample.maxGap > BLANK_LIMIT) + const summary = { + id: scenario.id, landing: scenario.landing, before: samples[0] === undefined ? null : rounded(samples[0]), + afterFold: rounded(afterFold), afterTurn: rounded(afterTurn), anchors, + peakGap: Math.round(Math.max(0, ...samples.map(sample => sample.maxGap))), + framesOverLimit: transient.length, frames: samples.length, + peakSpacer: Math.round(Math.max(0, ...samples.map(sample => sample.spacer))), + } + const evidence = async (): Promise => { + await mkdir(EVIDENCE, { recursive: true }) + await writeFile(join(EVIDENCE, `${sessionKey(scenario)}.json`), JSON.stringify({ scenario, ...summary, samples }, null, 2)) + console.log(`FOLDMX ${JSON.stringify(summary)}`) + } + // Per-frame evidence is written for failures, or for every case with FOLDMX_EVIDENCE=1. + if (process.env.FOLDMX_EVIDENCE === '1') await evidence() + else onTestFailed(evidence) + if (scenario.origin === 'parent-message' || scenario.origin === 'steer') { + expect.soft(samples.some(sample => sample.collapsing), 'a control case folds nothing').toBe(false) + } + if (scenario.roomUntilReply !== true) { + expect.soft(afterFold.maxGap, 'layout gap after the fold settles').toBeLessThanOrEqual(BLANK_LIMIT) + expect.soft(transient.length, 'animation frames with a layout gap').toBeLessThanOrEqual(TRANSIENT_FRAMES) + } + expect.soft(afterTurn.maxGap, 'layout gap after the Turn settles').toBeLessThanOrEqual(BLANK_LIMIT) + if (scenario.landing === 'tail') { + expect.soft(afterFold.floor - afterFold.top, 'distance from the floor after the fold').toBeLessThanOrEqual(1) + expect.soft(afterTurn.floor - afterTurn.top, 'distance from the floor after the Turn').toBeLessThanOrEqual(1) + expect.soft(afterTurn.following, 'following after the Turn').toBe(true) + } else if (scenario.anchorFolds !== true) { + expect.soft(anchors.before).not.toBeNull() + expect.soft(Math.abs((anchors.fold ?? Infinity) - (anchors.before ?? 0)), 'anchor movement after the fold').toBeLessThanOrEqual(2) + expect.soft(Math.abs((anchors.turn ?? Infinity) - (anchors.before ?? 0)), 'anchor movement after the Turn').toBeLessThanOrEqual(2) + } + expect.soft(world.tripwire.pageErrors).toEqual([]) + } finally { + // Scaffold close also asserts that every replay entry was consumed; its failures fail the case. + const failures: unknown[] = [] + await world?.page.context().close().catch((error: unknown) => failures.push(error)) + await world?.scaffold.close().catch((error: unknown) => failures.push(error)) + await rm(replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, `${scenario.id}: cleanup failed`) + } + }) + } +}) + +export {} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index b529ba1697..9103d289a5 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -24,6 +24,7 @@ "exclude": [ "tests/collapse-timing.e2e.ts", "tests/notice-wake-tail.e2e.ts", + "tests/fold-blank-matrix.e2e.ts", "tests/chat-flow-layout.e2e.ts", "tests/layout-bottom.e2e.ts", "tests/shortcuts-desktop.e2e.ts", diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 284abc5656..09494e01ab 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -289,7 +289,7 @@ export class Session implements SessionFace { // wire type is used; this array is not filtered or reordered. const routedContent = content as Exclude[] const routed = await this.remote.subagents.prompt({ - requestId: randomUUID() as SessionRequestId, + requestId: requestId ?? randomUUID() as SessionRequestId, parentSessionId: this.address.parentSessionId, childSessionId: this.address.childSessionId, mode: 'continuable', diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index ccba6d3a6a..8d1838e40f 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -513,6 +513,19 @@ describe('prompt and cancel errors', () => { expect(session.getSnapshot().subagent).toEqual({ address: CHILD, parentAvailable: true }) }) + it('sends an addressed child continuation under its echo request id and retires a refused echo', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) + await session.open() + const accepted = session.beginSubmission({ mode: 'queue', text: '继续', attachments: [] }) + await session.prompt([{ type: 'text', text: '继续' }], 'queue', undefined, accepted.requestId) + expect(mock.log.requests('subagents/prompt')).toMatchObject([{ requestId: accepted.requestId, ...CHILD }]) + expect(session.getSnapshot().pendingSubmissions.map(echo => echo.requestId)).toEqual([accepted.requestId]) + + const refused = session.beginSubmission({ mode: 'queue', text: '附件', attachments: [] }) + await session.prompt([{ type: 'file', receiptId: 'receipt' as never }], 'queue', undefined, refused.requestId) + expect(session.getSnapshot().pendingSubmissions.map(echo => echo.requestId)).toEqual([accepted.requestId]) + }) + it('forwards continuation image parts to the subagent prompt Remote unstripped', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) await session.open() diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index dd9033ae18..3f4ae6e1ae 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -36,8 +36,8 @@ en: 81d7ca7595c34d39 zh: 29eb8248c75e9ea6 /deepseek-ai-dsh-client-ui-chat/scroll-ownership: - en: 3f4e6c161b4583bb - zh: e4265bb2bce8eb1c + en: 284737900cb75668 + zh: 12e0844082ff79ce /deepseek-ai-dsh-client-ui-chat/model-experience: en: c17b4dd43a61c569 zh: f7414309810c3a08 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index f04957566b..0438804975 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -138,11 +138,11 @@ The transcript root uses `overflow-x: visible; overflow-y: clip`: vertical overf Outer transcript following and each open capped group's following are independent. Native animation progress retains follow intent; only that scrollport's own `scrollend` settles its animation. Browsers without `scrollend` may retain the native follow target until a later bottom-aligned update or reader gesture. 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. -Next-message folding combines row closure and tail following in one cancellable task, with browser scroll anchoring disabled during the fold. If the removed height exceeds the new content's height, the difference remains as bottom space rather than reducing the scroll offset to chase the unpadded content floor. This applies even when the conversation already exceeds the viewport. New content fills the retained space before following advances again; a smaller fold leaves only the remaining forward distance to scroll. Reader gestures cancel the task and active native following; resize cannot revive a cancelled task in the same fold, but new submitted input can. Off-bottom readers retain their position, and acknowledged scrolling releases only space that can be removed without clamping them. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes. +Next-message folding moves every batch of closing rows and the scrollport on one frame clock, with browser scroll anchoring disabled during the fold; there is no separate reserve-then-follow path. A row that starts closing or revealing while a batch runs keeps its size until that batch settles and then changes in the next batch; a batch continues each row from the size it already has. The batch lands at the tail when this client's own input caused it (including a subagent child's continuation) or when the reader was following; otherwise the reader's visible content stays still, whoever sent the message. Content above the reading line never slides back down: when the retained answer and the new content cannot fill the viewport, only that deficit remains as bottom space, and new content fills it before following advances again. Reader gestures cancel the automatic target and active native following; resize cannot revive it in the same fold, but new submitted input can. Acknowledged scrolling releases only space that can be removed without clamping the reader. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes. Changing Work details clears reserved bottom space after active folds finish and reconciles the scroll range with the selected mode. Readers already following the tail stay at the new bottom; other readers are not forced to follow. -Fold coordination is viewport-local: reservations and idle callbacks never broadcast to other Chat views. ChatView supplies its motion capability once to `conversation.chat.flow` through the render's `hookContext`. Flow binds the group and header behavior hooks, owns node dispatch, supplies Factory rendering for local echoes, and shares the existing Chat store and Session node sources. The image entry Factory owns the replaceable image Slot. Layout nodes and inline reasoning receive the bound hooks without receiving the motion controller or changing content parents. Generic searchable hiding applies before paint and owns focus protection and beforematch handling; pending-input reconciliation remains in ChatView. +Fold coordination is viewport-local: fold batches, bottom space, and idle callbacks never broadcast to other Chat views. ChatView supplies its motion capability once to `conversation.chat.flow` through the render's `hookContext`. Flow binds the group and header behavior hooks, owns node dispatch, supplies Factory rendering for local echoes, and shares the existing Chat store and Session node sources. The image entry Factory owns the replaceable image Slot. Layout nodes and inline reasoning receive the bound hooks without receiving the motion controller or changing content parents. Generic searchable hiding applies before paint and owns focus protection and beforematch handling; pending-input reconciliation remains in ChatView. | Outer follows | Open group follows | Back-to-bottom button | New content | |---|---|---|---| diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 064607a1a3..27d61eaa9a 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -138,11 +138,11 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且 外层文本记录与每个已展开限高组的跟随状态彼此独立。原生动画的中间滚动保留跟随意图;只有该滚动容器自身的 `scrollend` 才结束其动画。不支持 `scrollend` 的浏览器可能保留原生跟随目标,直到后续贴底更新或读者手势将其结束。读者手势中断动画,是否继续跟随由实际位移决定。滚动传递可以带动外层,外层再按自身离底距离判断。回到底部按钮只恢复外层跟随。 -下次消息触发的折叠与跟尾属于同一个可取消任务,折叠期间禁用浏览器滚动锚定。如果移除的高度大于新增内容的高度,差值保留为底部空间,不通过减小滚动偏移追到未含预留空间的内容底部;对话总高度已经超过视口时也遵循此规则。后续新增内容先填充保留区域,填满后才继续跟尾;折叠高度较小时,仅滚动剩余的向前距离。读者手势取消该任务及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复已取消的任务,但新提交的输入可以。离底读者保留位置,已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。 +下次消息触发的折叠中,每一批收起行与滚动容器在同一个帧时钟上推进,折叠期间禁用浏览器滚动锚定;不存在「先预留、再跟随」的另一条路径。批次运行期间开始收起或展开的行先保持当前尺寸,等当前批次落定后在下一批中变化;批次从每一行已有的尺寸继续。由本客户端自己的输入(包括子代理会话的 continuation)触发、或读者原本在跟尾时,这一批落到底部;其余情况下,无论消息由谁发出,读者正在看的内容保持不动。阅读线以上的内容不会回滑:保留的正文和新内容填不满视口时,只把差额保留为底部空间,后续新增内容先填满它,再继续跟尾。读者手势取消自动目标及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复它,但新提交的输入可以。已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。 切换工作步骤展示档位时,当前折叠结束后清空底部预留空间,并按所选档位调整滚动范围。原本跟随底部的读者保持在新的底部;其他读者不会被强制拉回底部。 -折叠协调状态由各自的 viewport 持有,空间预留和空闲回调不会广播到其他 Chat 视图。ChatView 通过本次渲染的 `hookContext`,向 `conversation.chat.flow` 交接一次动画能力。Flow 绑定组内容与组头的行为 hook,负责节点分发,为本地回显提供 Factory renderer,并共享既有 Chat store 与 Session 节点来源。图片入口 Factory 拥有可替换的图片 Slot。布局节点和行内思考接收绑定后的 hook,不接收动画控制器,也不改变内容的父级。通用的可搜索隐藏在绘制前生效,并负责焦点保护及 beforematch;pending 输入对账仍留在 ChatView。 +折叠协调状态由各自的 viewport 持有,折叠批次、底部空间和空闲回调不会广播到其他 Chat 视图。ChatView 通过本次渲染的 `hookContext`,向 `conversation.chat.flow` 交接一次动画能力。Flow 绑定组内容与组头的行为 hook,负责节点分发,为本地回显提供 Factory renderer,并共享既有 Chat store 与 Session 节点来源。图片入口 Factory 拥有可替换的图片 Slot。布局节点和行内思考接收绑定后的 hook,不接收动画控制器,也不改变内容的父级。通用的可搜索隐藏在绘制前生效,并负责焦点保护及 beforematch;pending 输入对账仍留在 ChatView。 | 外层跟随 | 已展开小组跟随 | 回到底部按钮 | 新增内容 | |---|---|---|---| diff --git a/packages/client/ui-chat/src/client/chat/flow-motion.ts b/packages/client/ui-chat/src/client/chat/flow-motion.ts index 9705f1f22a..6d941ecfdc 100644 --- a/packages/client/ui-chat/src/client/chat/flow-motion.ts +++ b/packages/client/ui-chat/src/client/chat/flow-motion.ts @@ -1,7 +1,8 @@ /** - * Fold transitions owned by one Chat viewport. Input folds coordinate box sizes and scrolling on - * one frame clock; other folds use CSS transitions. Motion is opt-in under `[data-chat-motion]`, - * respects reduced motion, and clears temporary styles. + * Fold transitions owned by one Chat viewport. Every batch containing a closing row moves its boxes and + * the scrollport on one frame clock; a batch that only reveals rows, or a viewport without a reading + * policy, uses CSS transitions. Motion is opt-in under `[data-chat-motion]`, respects reduced motion, + * and clears temporary styles. */ import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react' import { useSearchableHidden, type HiddenTransition } from './searchable-hidden.ts' @@ -24,7 +25,7 @@ export interface FlowMotionRows { cancelMotion(element: HTMLElement): void } -/** Geometry captured before one input-owned fold changes its boxes. */ +/** Geometry captured before one fold batch changes its boxes. */ export interface InputFoldBox { readonly element: HTMLElement readonly height: number @@ -32,29 +33,33 @@ export interface InputFoldBox { readonly closing: boolean } -/** The viewport follows the same progress that changes the folded boxes. */ +/** The viewport moves on the same progress that changes the folded boxes. */ export interface InputFoldDriver { /** - * Capture geometry before the frame clock changes the boxes. + * Capture geometry and choose the landing before the frame clock changes the boxes. * @param boxes - non-nested boxes whose layout changes on this clock. + * @returns closing boxes that scroll with the content and stay opaque; the rest fade in place. The driver + * may add boxes while the batch runs, when a newer tail request takes it over. */ - begin(boxes: readonly InputFoldBox[]): void + begin(boxes: readonly InputFoldBox[]): ReadonlySet /** - * Reserve scroll range before box writes; omit when the driver needs no pre-write compensation. + * Reserve scroll range before box writes. * @param progress - shared eased progress from zero to one. - * @param cancelled - automatic following has stopped; reader-position compensation still applies. + * @param cancelled - a reading gesture stopped a tail landing's automatic target; a landing that keeps + * the reader's content still continues and includes the reader's own movement. */ - prepare?(progress: number, cancelled: boolean): void + prepare(progress: number, cancelled: boolean): void /** - * Apply viewport geometry after box writes, even when automatic following is cancelled. + * Apply viewport geometry after box writes. * @param progress - shared eased progress from zero to one. - * @param cancelled - preserve the reader's position rather than applying the automatic target. + * @param cancelled - a reading gesture stopped a tail landing's automatic target. * @returns actual scrollTop displacement from the batch start, for closing-content visual compensation. */ frame(progress: number, cancelled: boolean): number /** - * Release temporary geometry after the last frame or cancellation. - * @param cancelled - clean up without reacquiring follow ownership or committing an obsolete landing. + * Install the final geometry after the last frame or cancellation. + * @param cancelled - a reading gesture stopped the automatic target, every closing row reopened, or the + * viewport detached; the driver commits no obsolete landing. */ finish(cancelled: boolean): void } @@ -65,11 +70,11 @@ export interface FlowMotion extends FlowMotionRows { foldActive(): boolean /** @param listener - callback after the last fold. @returns cancellation for a pending callback. */ onFoldIdle(listener: () => void): () => void - /** @param driver - one input-owned viewport motion. @returns whether an active fold was adopted. */ - followInput(driver: InputFoldDriver): boolean - /** Stop input-owned scrolling while letting its closing rows finish. */ + /** Stop the running batch's automatic target while letting its boxes finish. */ cancelInput(): void - /** @returns whether a coordinated fold currently owns layout and scrolling. */ + /** Restore the running batch's automatic target after a newer request takes it over. */ + resumeInput(): void + /** @returns whether a fold batch runs, or one is scheduled for this commit's closing rows. */ inputActive(): boolean /** Cancel owned animations and pending work when the viewport detaches. */ clear(): void @@ -94,113 +99,191 @@ export function hiddenOrCollapsing(element: HTMLElement): boolean { return element.hasAttribute('hidden') || element.dataset.chatMotion === 'collapse' } +/** + * Opacity of a revealing box at batch progress, continuing from the share its own transition reached. + * @param box - revealing box. + * @param progress - shared eased progress from zero to one. + * @returns opacity from zero to one. + */ +function revealedShare(box: OwnedBox, progress: number): number { + const start = box.height === 0 ? 0 : box.fromHeight / box.height + return start + (1 - start) * progress +} + +/** A box this viewport owns until it finishes or is cancelled. */ +interface OwnedBox { + readonly element: HTMLElement + /** + * Full size: a closing box shrinks from it, a revealing box grows to it. A batch re-measures a closing + * box's current size when it takes the box over. + */ + height: number + marginTop: number + /** Size a revealing box had already reached on its own transition when a batch took it over. */ + fromHeight: number + fromMarginTop: number + readonly closing: boolean + /** The box changed while a batch ran; it waits without a CSS transition until the next batch. */ + held: boolean + /** Start a held revealing box's own transition when no batch will drive it. */ + release(): void + /** Stop the box's own transition and fallback timer before a batch drives it. */ + take(): void + finish(): void +} + /** * Create animation coordination without cross-viewport broadcasts. - * @param reserve - reserve the deduplicated removed height in this viewport. + * @param driver - the viewport's driver for the next batch; null while detached. * @param foldingChanged - disable or restore this viewport's native scroll anchoring. * @returns stable callbacks and cleanup for this viewport's animations. */ -export function createFlowMotion(reserve: (px: number) => void, foldingChanged: (active: boolean) => void): FlowMotion { +export function createFlowMotion(driver: () => InputFoldDriver | null, foldingChanged: (active: boolean) => void): FlowMotion { const running = new Map void>() let folding = 0 const foldIdleListeners = new Set<() => void>() - const pendingReserve = new Set() - let pendingGrowth = 0 - let reserveScheduled = false - const boxes = new Map void; finish: () => void }>() - let inputDriver: InputFoldDriver | null = null - let inputFrame: number | null = null - let inputBatch = false + let batchScheduled = false + const boxes = new Map() + let frameRequest: number | null = null + let batch = false + let cancelled = false let clearEpoch = 0 - let inputCleanup: (() => void) | null = null - let activeDriver: InputFoldDriver | null = null + let batchCleanup: (() => void) | null = null let cancelEmptyBatch: (() => void) | null = null + /** The batch is committing hidden rows and its final geometry; idle listeners wait until both land. */ + let settling = false + let anchoringDisabled = false - function cancelInput(): void { inputDriver = null } - - function followInput(driver: InputFoldDriver): boolean { - if (folding === 0) return false - if (inputBatch) { - inputDriver = activeDriver - return true - } - inputDriver = driver - scheduleReserve() - return true + function cancelInput(): void { + if (batch) cancelled = true } - function runInputBatch(): void { - const driver = inputDriver - if (driver === null || inputBatch) return - const owned = [...boxes.values()] - const batch = owned.filter(box => box.element.parentElement?.closest('[data-chat-motion="collapse"]') === null) - .sort((left, right) => left.element.compareDocumentPosition(right.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1) - if (!batch.some(box => box.closing)) return - inputBatch = true - activeDriver = driver - driver.begin(batch) - inputCleanup = () => { driver.finish(true) } - for (const box of owned) box.take() - for (const box of batch) { - box.element.style.transition = 'none' - box.element.style.height = `${box.closing ? box.height : 0}px` - box.element.style.marginTop = `${box.closing ? box.marginTop : 0}px` - box.element.style.opacity = box.closing ? '1' : '0' + function resumeInput(): void { + if (batch) cancelled = false + } + + /** Rows inside a closing ancestor change with it and never join a batch on their own. */ + function batchMember(box: InputFoldBox): boolean { + return box.element.parentElement?.closest('[data-chat-motion="collapse"]') === null + } + + function runBatch(current: InputFoldDriver): void { + // Every member continues from the size it has now, so the driver's measured layout and its change + // amounts describe the same boxes: a closing box may have moved on its own transition or sit inside a + // row that finished closing, and a revealing box may have grown part of the way on its own transition. + for (const box of [...boxes.values()]) { + if (!batchMember(box)) continue + const height = box.element.offsetHeight + const marginTop = Number.parseFloat(getComputedStyle(box.element).marginTop) || 0 + if (!box.closing) { + box.fromHeight = Math.min(height, box.height) + box.fromMarginTop = Math.min(marginTop, box.marginTop) + continue + } + box.height = height + box.marginTop = marginTop + if (height === 0 && marginTop === 0) box.finish() } + const owned = [...boxes.values()] + const members = owned.filter(batchMember) + .sort((left, right) => left.element.compareDocumentPosition(right.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1) + if (!members.some(box => box.closing)) return + batch = true + cancelled = false + const travelling = current.begin(members.map(box => box.closing ? box : { + element: box.element, + height: box.height - box.fromHeight, + marginTop: box.marginTop - box.fromMarginTop, + closing: false, + })) + batchCleanup = () => { current.finish(true) } + for (const box of owned) box.take() + for (const box of members) { + box.element.style.transition = 'none' + box.element.style.height = `${box.closing ? box.height : box.fromHeight}px` + box.element.style.marginTop = `${box.closing ? box.marginTop : box.fromMarginTop}px` + box.element.style.opacity = box.closing ? '1' : String(revealedShare(box, 0)) + } + const memberSet = new Set(members) + const settledTravel = new Set() + // A travelling box clips its content as it shrinks, so nested closing rows keep their size until it ends. + const freezeTravelling = (): void => { + for (const element of travelling) { + if (settledTravel.has(element)) continue + settledTravel.add(element) + element.style.translate = '' + for (const box of owned) { + if (memberSet.has(box) || !element.contains(box.element)) continue + box.element.style.transition = 'none' + box.element.style.height = `${box.height}px` + box.element.style.marginTop = `${box.marginTop}px` + box.element.style.opacity = '1' + } + } + } + freezeTravelling() const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches let started: number | undefined let displacement = 0 - cancelEmptyBatch = () => { - if (batch.some(box => box.closing && boxes.get(box.element) === box)) return - if (inputFrame !== null) cancelAnimationFrame(inputFrame) - inputFrame = null - inputBatch = false - activeDriver = null - inputDriver = null - inputCleanup = null + const settle = (finishCancelled: boolean): void => { + batch = false + batchCleanup = null cancelEmptyBatch = null - for (const box of owned) if (boxes.get(box.element) === box) box.finish() - driver.finish(true) + settling = true + try { + for (const box of owned) if (boxes.get(box.element) === box) box.finish() + current.finish(finishCancelled) + } finally { + settling = false + } + if (folding === 0) notifyIdle() + // Rows that changed while this batch ran wait in place; the next flush drives or releases them. + if ([...boxes.values()].some(box => box.held)) flushBatch() + } + cancelEmptyBatch = () => { + if (members.some(box => box.closing && boxes.get(box.element) === box)) return + if (frameRequest !== null) cancelAnimationFrame(frameRequest) + frameRequest = null + settle(true) } const tick = (now: number): void => { - inputFrame = null + frameRequest = null started ??= now const elapsed = reduced ? CLOSE_DELAY_MS + CLOSE_MS : now - started const linear = Math.min(1, Math.max(0, (elapsed - CLOSE_DELAY_MS) / CLOSE_MS)) const progress = linear * linear * (3 - 2 * linear) - driver.prepare?.(progress, inputDriver !== driver) - for (const box of batch) { + current.prepare(progress, cancelled) + freezeTravelling() + for (const box of members) { if (boxes.get(box.element) !== box) continue - const remaining = box.closing ? 1 - progress : progress - box.element.style.height = `${box.height * remaining}px` - box.element.style.marginTop = `${box.marginTop * remaining}px` - box.element.style.opacity = box.closing ? String(Math.max(0, 1 - elapsed / FADE_OUT_MS)) : String(progress) + if (box.closing) { + box.element.style.height = `${box.height * (1 - progress)}px` + box.element.style.marginTop = `${box.marginTop * (1 - progress)}px` + box.element.style.opacity = travelling.has(box.element) ? '1' : String(Math.max(0, 1 - elapsed / FADE_OUT_MS)) + } else { + box.element.style.height = `${box.fromHeight + (box.height - box.fromHeight) * progress}px` + box.element.style.marginTop = `${box.fromMarginTop + (box.marginTop - box.fromMarginTop) * progress}px` + box.element.style.opacity = String(revealedShare(box, progress)) + } } - displacement = driver.frame(progress, inputDriver !== driver) + displacement = current.frame(progress, cancelled) let removedBefore = 0 - for (const box of batch) { + for (const box of members) { if (boxes.get(box.element) !== box) continue if (box.closing) { // Transforming the closing box changes its pixels, not its flow contribution. - box.element.style.translate = `0 ${displacement + removedBefore}px` + if (!travelling.has(box.element)) box.element.style.translate = `0 ${displacement + removedBefore}px` removedBefore += (box.height + box.marginTop) * progress - } else removedBefore -= (box.height + box.marginTop) * progress + } else removedBefore -= (box.height - box.fromHeight + box.marginTop - box.fromMarginTop) * progress } if (linear < 1) { - inputFrame = requestAnimationFrame(tick) + frameRequest = requestAnimationFrame(tick) return } - for (const box of owned) box.finish() - inputBatch = false - activeDriver = null - inputCleanup = null - cancelEmptyBatch = null - const cancelled = inputDriver !== driver - if (!cancelled) inputDriver = null - driver.finish(cancelled) + settle(cancelled) } if (reduced) tick(0) - else inputFrame = requestAnimationFrame(tick) + else frameRequest = requestAnimationFrame(tick) } function foldActive(): boolean { return folding > 0 } @@ -211,54 +294,58 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: return () => { foldIdleListeners.delete(listener) } } - function flushReserve(): void { - reserveScheduled = false - if (inputDriver !== null && !inputBatch) { - pendingReserve.clear() - pendingGrowth = 0 - runInputBatch() + function flushBatch(): void { + batchScheduled = false + // Rows that change during a batch are held until it settles, which flushes again. + if (batch) return + const current = [...boxes.values()].some(box => box.closing) ? driver() : null + if (current !== null) { + runBatch(current) return } - if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) { - pendingReserve.clear() - pendingGrowth = 0 - for (const box of [...boxes.values()]) box.finish() - return + const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches + // Without a batch, a held closing row finishes at once and a held revealing row starts its own transition. + for (const box of [...boxes.values()]) { + if (reduced || (box.held && box.closing)) box.finish() + else if (box.held) box.release() } - let total = -pendingGrowth - pendingGrowth = 0 - for (const element of pendingReserve) { - // A member inside a closing group is already part of the group's own box. - if (element.parentElement?.closest('[data-chat-motion="collapse"]') !== null) continue - total += element.offsetHeight + (Number.parseFloat(getComputedStyle(element).marginTop) || 0) - } - pendingReserve.clear() - if (total > 0) reserve(total) } - function scheduleReserve(): void { - if (reserveScheduled) return - reserveScheduled = true + function scheduleBatch(): void { + if (batchScheduled) return + batchScheduled = true // After the whole commit's collapses and reveals have started, before any box changes size. const epoch = clearEpoch - queueMicrotask(() => { if (epoch === clearEpoch) flushReserve() }) + queueMicrotask(() => { if (epoch === clearEpoch) flushBatch() }) } function foldStarted(): void { folding += 1 - if (folding === 1) foldingChanged(true) + if (folding !== 1 || anchoringDisabled) return + anchoringDisabled = true + foldingChanged(true) } function foldEnded(): void { folding = Math.max(0, folding - 1) - if (folding > 0) return - foldingChanged(false) + if (folding === 0 && !settling) notifyIdle() + } + + function notifyIdle(): void { + if (anchoringDisabled) { + anchoringDisabled = false + foldingChanged(false) + } const listeners = [...foldIdleListeners] foldIdleListeners.clear() for (const listener of listeners) listener() } - function clearMotion(element: HTMLElement, onEnd: (event: TransitionEvent) => void, timer: ReturnType): void { + function clearMotion( + element: HTMLElement, + onEnd: (event: TransitionEvent) => void, + timer: ReturnType | undefined, + ): void { element.removeEventListener('transitionend', onEnd) clearTimeout(timer) running.delete(element) @@ -274,7 +361,6 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: } function cancelMotion(element: HTMLElement): void { - pendingReserve.delete(element) running.get(element)?.() cancelEmptyBatch?.() } @@ -288,20 +374,23 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: return } const { style } = element + // A running batch owns the scrollport; a row closing now keeps its size until the next batch drives it. + const held = batch element.dataset.chatMotion = 'collapse' foldStarted() - pendingReserve.add(element) - scheduleReserve() + scheduleBatch() style.transition = 'none' style.overflow = 'hidden' style.height = `${height}px` style.marginTop = `${marginTop}px` - void element.offsetHeight - style.transition = `opacity ${FADE_OUT_MS}ms ${EASING}, ` - + `height ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms, margin-top ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms` - style.opacity = '0' - style.height = '0px' - style.marginTop = '0px' + if (!held) { + void element.offsetHeight + style.transition = `opacity ${FADE_OUT_MS}ms ${EASING}, ` + + `height ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms, margin-top ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms` + style.opacity = '0' + style.height = '0px' + style.marginTop = '0px' + } // The hidden state must land before fold-idle listeners measure the flow. const finish = (): void => { @@ -313,14 +402,15 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: const onEnd = (event: TransitionEvent): void => { if (event.target === element && event.propertyName === 'height') finish() } - const timer = setTimeout(finish, CLOSE_DELAY_MS + CLOSE_MS + SETTLE_SLACK_MS) + const timer = held ? undefined : setTimeout(finish, CLOSE_DELAY_MS + CLOSE_MS + SETTLE_SLACK_MS) const cancel = (): void => { clearMotion(element, onEnd, timer) foldEnded() } - element.addEventListener('transitionend', onEnd) + if (!held) element.addEventListener('transitionend', onEnd) running.set(element, cancel) - boxes.set(element, { element, height, marginTop, closing: true, finish, + boxes.set(element, { element, height, marginTop, fromHeight: height, fromMarginTop: marginTop, closing: true, held, finish, + release: () => {}, take: () => { element.removeEventListener('transitionend', onEnd); clearTimeout(timer) } }) } @@ -329,8 +419,7 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: const height = element.offsetHeight if (height === 0) return const marginTop = Number.parseFloat(getComputedStyle(element).marginTop) || 0 - pendingGrowth += height + marginTop - scheduleReserve() + scheduleBatch() const { style } = element element.dataset.chatMotion = 'reveal' style.transition = 'none' @@ -338,12 +427,6 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: style.opacity = '0' style.height = '0px' style.marginTop = '0px' - void element.offsetHeight - style.transition = `height ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms, margin-top ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms, ` - + `opacity ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms` - style.height = `${height}px` - style.marginTop = `${marginTop}px` - style.opacity = '1' const finish = (): void => { if (running.get(element) !== finish) return clearMotion(element, onEnd, timer) @@ -351,31 +434,48 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: const onEnd = (event: TransitionEvent): void => { if (event.target === element && event.propertyName === 'height') finish() } - const timer = setTimeout(finish, CLOSE_DELAY_MS + CLOSE_MS + SETTLE_SLACK_MS) - element.addEventListener('transitionend', onEnd) + let timer: ReturnType | undefined + const startTransition = (): void => { + void element.offsetHeight + style.transition = `height ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms, margin-top ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms, ` + + `opacity ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms` + style.height = `${height}px` + style.marginTop = `${marginTop}px` + style.opacity = '1' + timer = setTimeout(finish, CLOSE_DELAY_MS + CLOSE_MS + SETTLE_SLACK_MS) + element.addEventListener('transitionend', onEnd) + } + // A running batch owns the scrollport; a row revealed now waits at zero size until the next flush. + const held = batch + if (!held) startTransition() running.set(element, finish) - boxes.set(element, { element, height, marginTop, closing: false, finish, - take: () => { element.removeEventListener('transitionend', onEnd); clearTimeout(timer) } }) + const box: OwnedBox = { element, height, marginTop, fromHeight: 0, fromMarginTop: 0, closing: false, held, finish, + release: () => { + box.held = false + startTransition() + }, + take: () => { element.removeEventListener('transitionend', onEnd); clearTimeout(timer) } } + boxes.set(element, box) } function clear(): void { clearEpoch++ - reserveScheduled = false - inputDriver = null - activeDriver = null - inputBatch = false - if (inputFrame !== null) cancelAnimationFrame(inputFrame) - inputFrame = null - inputCleanup?.() - inputCleanup = null + batchScheduled = false + batch = false + cancelled = false + if (frameRequest !== null) cancelAnimationFrame(frameRequest) + frameRequest = null + batchCleanup?.() + batchCleanup = null cancelEmptyBatch = null foldIdleListeners.clear() - pendingReserve.clear() - pendingGrowth = 0 for (const cancel of [...running.values()]) cancel() } - return { collapse, reveal, cancelMotion, foldActive, onFoldIdle, followInput, cancelInput, inputActive: () => inputBatch, clear } + return { + collapse, reveal, cancelMotion, foldActive, onFoldIdle, cancelInput, resumeInput, clear, + inputActive: () => batch || (batchScheduled && [...boxes.values()].some(box => box.closing && batchMember(box))), + } } function useFlowTransition(motion: FlowMotionRows | undefined, grow: boolean): HiddenTransition | undefined { diff --git a/packages/client/ui-chat/src/client/chat/use-chat-reading.ts b/packages/client/ui-chat/src/client/chat/use-chat-reading.ts index 3d3c6ae048..cca1c11a39 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-reading.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-reading.ts @@ -88,13 +88,26 @@ export class ChatReading { } /** - * Follow submitted input on the active fold's animation clock. - * @returns whether this input joined a closing batch instead of starting another scroll animation. + * Follow the tail on the active fold's animation clock. + * @returns whether a fold batch owns the tail movement instead of a separate scroll animation. */ followInputFold(): boolean { this.cancelPending() - return this.viewport.followInputFold(this.follow, (landing) => { - this.commit(landing, true, this.viewport.latestTurn) + return this.viewport.followInputFold(this.follow) + } + + /** + * Let fold batches read follow intent and commit their tail landings. + * @returns a disposer that disconnects this reading policy from the viewport. + */ + bindFolds(): () => void { + return this.viewport.bindReader({ + follow: this.follow, + landedAtTail: (landing) => { + this.cancelPending() + this.commit(landing, true, this.viewport.latestTurn) + }, + pending: () => this.pending, }) } @@ -263,6 +276,7 @@ export function useChatReading( 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.bindFolds(), [reading]) useLayoutEffect(() => () => { reading.dispose() }, [reading]) return { reading, state } } diff --git a/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts b/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts index 8f867de148..0fd20082e3 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts @@ -56,30 +56,9 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { const content = useRef<{ input: ChatScrollInput; applied: ChatScrollInput | null; opened: boolean }>({ input, applied: null, opened: false, }) - // A fold larger than the new content leaves bottom room rather than reversing the scrollport. - // Growth consumes that room before automatic following advances toward newer content. - const cancelFollow = useRef<(() => void) | null>(null) - const clearPendingFollow = useCallback(() => { - cancelFollow.current?.() - cancelFollow.current = null - }, []) - const cancelPendingFollow = useCallback(() => { - viewport.motion.cancelInput() - clearPendingFollow() - // Retain a cancelled wait until this fold ends, so resize cannot reacquire follow ownership. - if (viewport.motion.foldActive()) { - cancelFollow.current = viewport.motion.onFoldIdle(() => { cancelFollow.current = null }) - } - }, [clearPendingFollow, viewport]) - const followAfterFold = useCallback(() => { - if (cancelFollow.current !== null || viewport.motion.inputActive()) return - reading.interruptFollow() - cancelFollow.current = viewport.motion.onFoldIdle(() => { - cancelFollow.current = null - viewport.reclaimBelow() - if (content.current.input.deferCompletedTurns) reading.followTail('smooth') - }) - }, [reading, viewport]) + // A fold batch owns layout and scrolling until it lands: at the tail for own input or a following + // reader, otherwise with the reader's content still. Growth consumes any room the landing installed. + const cancelPendingFollow = useCallback(() => { viewport.motion.cancelInput() }, [viewport]) const processContent = useCallback(() => { const current = content.current.input @@ -99,7 +78,6 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { return } if (ownInput) { - clearPendingFollow() navigation.cancel() if (current.deferCompletedTurns && reading.followInputFold()) return reading.followTail(current.deferCompletedTurns ? 'smooth' : 'instant') @@ -115,10 +93,10 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { || current.steeringId !== previous.steeringId || current.submissionId !== previous.submissionId if (tipChanged && reading.followingTail) { navigation.cancel() - if (current.deferCompletedTurns && viewport.motion.foldActive()) followAfterFold() - else reading.followTail() + if (current.deferCompletedTurns && reading.followInputFold()) return + reading.followTail() } else navigation.reconcile() - }, [viewport, reading, navigation, followAfterFold, clearPendingFollow]) + }, [reading, navigation]) useLayoutEffect(() => { const disconnectViewport = viewport.connect({ @@ -139,9 +117,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { navigation.reconcile() return } - if (content.current.input.deferCompletedTurns && viewport.motion.foldActive()) { - if (!reading.pending && reading.followingTail) followAfterFold() - } else reading.onResize() + if (!content.current.input.deferCompletedTurns || !viewport.motion.foldActive()) reading.onResize() navigation.reconcile() }, }) @@ -150,13 +126,12 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { processContent() }) return () => { - clearPendingFollow() disconnectViewport() disconnectReading() content.current.opened = false content.current.applied = null } - }, [viewport, reading, navigation, processContent, followAfterFold, cancelPendingFollow, clearPendingFollow]) + }, [viewport, reading, navigation, processContent, cancelPendingFollow]) useLayoutEffect(() => { const previous = content.current.input diff --git a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts index ad6de91901..40ea51de98 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts @@ -2,7 +2,7 @@ import { useLayoutEffect, useRef, useState, type RefObject } from 'react' import type { ChatScrollPosition } from '../contract/slots.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' -import { createFlowMotion } from './flow-motion.ts' +import { createFlowMotion, type InputFoldBox, type InputFoldDriver } from './flow-motion.ts' import { scrollMetrics, ScrollFollow, type ViewportMetrics } from './use-scroll-follow.ts' export type { ViewportMetrics } from './use-scroll-follow.ts' @@ -37,6 +37,16 @@ interface ViewportElements { readonly spacer: HTMLElement | null } +/** Reading policy that fold batches consult and report tail landings to. */ +export interface FoldReader { + /** Follow intent at batch start selects the tail landing; tail landings commit it. */ + readonly follow: ScrollFollow + /** @param landing - the actual geometry after a batch landed at the tail. */ + readonly landedAtTail: (landing: ViewportLanding) => void + /** @returns whether reader movement awaits sampling; until then follow intent is undecided. */ + readonly pending: () => boolean +} + interface PagingPosition { readonly row: HTMLElement readonly position: ChatScrollPosition @@ -54,9 +64,14 @@ export class ChatViewport { private turns: ReturnType = [] private observation: { top: number; landing: ViewportLanding | null } = { top: 0, landing: null } private paging: PagingPosition | null = null + private reader: FoldReader | null = null + /** The pending or running fold batch lands at the tail. */ + private tailRequested = false + /** Switches the running batch to a tail landing; null while no batch runs. */ + private retarget: (() => void) | null = null /** Transition state and row callbacks owned by this viewport's attachment. */ readonly motion = createFlowMotion( - (px) => { this.reserveBelow(px) }, + () => this.foldDriver(), (active) => { if (this.elements !== null) this.elements.scroller.style.overflowAnchor = active ? 'none' : '' }, @@ -103,6 +118,8 @@ export class ChatViewport { this.observer = null this.elements = null this.events = null + this.tailRequested = false + this.retarget = null this.turns = [] this.observation = { top: 0, landing: null } } @@ -151,114 +168,241 @@ export class ChatViewport { } /** - * Add room below the flow before rows above the reader close, so the scrollport never clamps: - * the closing gap pulls the content below it upward while everything above stays put. - * @param px - height about to leave the flow. + * Connect the reading policy that fold batches consult. + * @param reader - follow intent and the tail-landing commit. + * @returns a disposer that disconnects only this reader. */ - reserveBelow(px: number): void { - if (px <= 0) return - this.setSpacer(this.spacerHeight() + px) + bindReader(reader: FoldReader): () => void { + this.reader = reader + return () => { if (this.reader === reader) this.reader = null } } /** - * Join an input-caused fold and its tail movement on the fold's frame clock. - * @param follow - the existing reader's follow controller. - * @param settled - commits follow ownership after the final geometry is installed. - * @returns whether a closing batch owns this input. + * Land the pending or running fold batch at the tail instead of starting a separate scroll. + * @param follow - follow controller whose native animation yields to the batch. + * @returns whether a fold owns the tail movement. */ - followInputFold(follow: ScrollFollow, settled: (landing: ViewportLanding) => void): boolean { - const elements = this.elements - if (elements === null) return false + followInputFold(follow: ScrollFollow): boolean { + // Rows still closing on CSS transitions after a batch have no batch to consume the request. + if (this.elements === null || this.reader === null || !this.motion.inputActive()) return false this.interruptFollow(follow) + this.tailRequested = true + this.retarget?.() + return true + } + + /** + * Drive one fold batch on its frame clock. A tail landing ends at the floor, never above the reader's + * compensated position; a reader landing keeps the visible content still. Both install only the room + * the landing lacks. + * @returns the batch driver, or null while detached or without a reading policy. + */ + private foldDriver(): InputFoldDriver | null { + const elements = this.elements + const reader = this.reader + if (elements === null || reader === null) return null + const { follow } = reader + let tail = false let start = 0 + let base = 0 let target = 0 let removed = 0 - let initialHeight = 0 - let originalRoom = 0 - let finalRoom = 0 let growth = 0 - let base = 0 + let initialHeight = 0 + let finalRoom = 0 let previousColumnHeight = 0 let previousProgress = 0 let segmentProgress = 0 let segmentTop = 0 - return this.motion.followInput({ + let written = 0 + const closing: InputFoldBox[] = [] + const travelling = new Set() + // A visible row outside the batch measures spacing that snaps when the batch starts and ends. + let reference: HTMLElement | null = null + let referenceTop = 0 + let viewportTop = 0 + const screenTop = (element: HTMLElement): number => element.getBoundingClientRect().top - viewportTop + const aim = (): void => { + const finalHeight = initialHeight - removed + growth + const height = elements.scroller.clientHeight + target = tail ? Math.max(base, finalHeight - height, 0) : base + finalRoom = target === 0 ? 0 : Math.max(0, target + height - finalHeight) + } + const restart = (): void => { + segmentTop = elements.scroller.scrollTop + segmentProgress = previousProgress + } + const automatic = (cancelled: boolean): boolean => !(cancelled && tail) + const position = (progress: number, cancelled: boolean): number => { + if (!automatic(cancelled)) return elements.scroller.scrollTop + const remaining = segmentProgress === 1 ? 1 : (progress - segmentProgress) / (1 - segmentProgress) + return segmentTop + (target - segmentTop) * remaining + } + const room = (top: number, progress: number, cancelled: boolean): number => Math.max( + automatic(cancelled) ? finalRoom : 0, + top + elements.scroller.clientHeight - (initialHeight + (growth - removed) * progress), + ) + return { begin: (boxes) => { - const viewport = elements.scroller.getBoundingClientRect() - const metrics = scrollMetrics(elements.scroller) - start = metrics.top - originalRoom = this.spacerHeight() + // Own input lands at the tail; otherwise an unsampled reading movement outranks follow intent. + tail = this.tailRequested || (follow.active && !reader.pending()) + this.tailRequested = false + viewportTop = elements.scroller.getBoundingClientRect().top + start = elements.scroller.scrollTop + const originalRoom = this.spacerHeight() initialHeight = Math.max(elements.column.offsetHeight, elements.scroller.scrollHeight - originalRoom) let above = 0 + const below: HTMLElement[] = [] for (const box of boxes) { const amount = box.height + box.marginTop + const top = box.element.getBoundingClientRect().top - box.marginTop if (box.closing) { removed += amount - const top = box.element.getBoundingClientRect().top - box.marginTop - above += Math.max(0, Math.min(amount, viewport.top - top)) - } else growth += amount + above += Math.max(0, Math.min(amount, viewportTop - top)) + if (top >= viewportTop) below.push(box.element) + closing.push(box) + } else { + growth += amount + if (top < viewportTop) above -= amount + } + } + if (!tail) reference = this.readingReference() + const referenceBefore = reference === null ? 0 : screenTop(reference) + elements.column.dataset.inputFold = '' + if (reference !== null) { + referenceTop = screenTop(reference) + above -= referenceTop - referenceBefore } - const finalHeight = initialHeight - removed + growth base = Math.max(0, start - above) segmentTop = start + written = start previousColumnHeight = elements.column.offsetHeight - target = Math.max(base, Math.max(0, finalHeight - metrics.height)) - finalRoom = target === 0 ? 0 : Math.max(0, target + metrics.height - finalHeight) - elements.column.dataset.inputFold = '' + aim() // This reserve only prevents an intermediate clamp; the final frame installs the exact deficit. this.setSpacer(Math.max(originalRoom, finalRoom, removed)) - follow.setFollowing(true) + if (tail) follow.setFollowing(true) + // A newer tail request also takes over a batch whose target a reading gesture cancelled. + this.retarget = () => { + tail = true + follow.setFollowing(true) + this.motion.resumeInput() + aim() + restart() + if (target <= segmentTop) return + for (const box of closing) { + // A box fading in place carries a visual translate; its layout position excludes it. + const shift = Number.parseFloat(box.element.style.translate.split(' ')[1] ?? '') || 0 + if (box.element.getBoundingClientRect().top - shift - box.marginTop >= viewportTop) travelling.add(box.element) + } + } + // Scrolling forward past closing rows shows them shrinking; faded rows would leave their space empty. + if (tail && target > start) for (const element of below) travelling.add(element) + return travelling }, prepare: (progress, cancelled) => { if (this.elements !== elements) return + if (!tail) { + // Reader scrolling between frames moves the reading position the batch keeps still. + const drift = elements.scroller.scrollTop - written + if (Math.abs(drift) > 0.5) { + base = Math.max(0, base + drift) + segmentTop += drift + // The reference moves on screen with the reader's scroll; only layout changes are corrected. + referenceTop -= drift + aim() + } + } const added = elements.column.offsetHeight - previousColumnHeight if (added !== 0) { initialHeight += added - const finalHeight = initialHeight - removed + growth - target = Math.max(base, finalHeight - elements.scroller.clientHeight, 0) - finalRoom = target === 0 ? 0 : Math.max(0, target + elements.scroller.clientHeight - finalHeight) - segmentTop = elements.scroller.scrollTop - segmentProgress = previousProgress + aim() + restart() } - const remaining = segmentProgress === 1 ? 1 : (progress - segmentProgress) / (1 - segmentProgress) - const top = cancelled ? elements.scroller.scrollTop : segmentTop + (target - segmentTop) * remaining - const currentHeight = initialHeight + (growth - removed) * progress - const room = Math.max(cancelled ? 0 : finalRoom, top + elements.scroller.clientHeight - currentHeight) + const top = position(progress, cancelled) // Growing the room before shrinking boxes avoids a native clamp between writes. - this.setSpacer(Math.max(this.spacerHeight(), room)) + this.setSpacer(Math.max(this.spacerHeight(), room(top, progress, cancelled))) }, frame: (progress, cancelled) => { if (this.elements !== elements) return 0 - const remaining = segmentProgress === 1 ? 1 : (progress - segmentProgress) / (1 - segmentProgress) - const top = cancelled ? elements.scroller.scrollTop : segmentTop + (target - segmentTop) * remaining - const currentHeight = initialHeight + (growth - removed) * progress - const room = Math.max(cancelled ? 0 : finalRoom, top + elements.scroller.clientHeight - currentHeight) + const top = position(progress, cancelled) elements.scroller.scrollTop = top - this.setSpacer(room) - if (!cancelled) this.observation = { top: elements.scroller.scrollTop, landing: null } + this.setSpacer(room(top, progress, cancelled)) + written = elements.scroller.scrollTop + // Earlier frames keep the reference still; only its final position is compared at finish. + if (reference !== null && progress === 1) referenceTop = screenTop(reference) + if (automatic(cancelled)) this.observation = { top: written, landing: null } previousColumnHeight = elements.column.offsetHeight previousProgress = progress - return elements.scroller.scrollTop - start + return written - start }, finish: (cancelled) => { + this.retarget = null + this.tailRequested = false if (this.elements !== elements) return - // Commit the final sibling margins while their input-fold transition override still applies. + // Commit the final sibling margins while their fold transition override still applies. void elements.column.offsetHeight delete elements.column.dataset.inputFold - if (cancelled) { + if (!tail || cancelled) { + if (!tail && reference?.isConnected === true && reference.closest('[hidden]') === null) { + // Committing the hidden rows settles their neighbours' spacing; keep the reference still. + const shift = screenTop(reference) - referenceTop + if (Math.abs(shift) > 0.5) { + const top = elements.scroller.scrollTop + shift + const content = elements.scroller.scrollHeight - this.spacerHeight() + this.setSpacer(Math.max(this.spacerHeight(), top + elements.scroller.clientHeight - content)) + elements.scroller.scrollTop = top + } + } const metrics = scrollMetrics(elements.scroller) const height = elements.scroller.scrollHeight - this.spacerHeight() this.setSpacer(metrics.top === 0 ? 0 : Math.max(0, metrics.top + metrics.height - height)) - this.invalidate() + if (tail) this.invalidate() + else this.observation = { top: elements.scroller.scrollTop, landing: null } return } - this.setSpacer(finalRoom) + // The estimate can differ from the committed layout, for example settled sibling spacing. + // Land on the actual content: never above the target, and at the real floor when content reaches past it. + const content = elements.scroller.scrollHeight - this.spacerHeight() + const clientHeight = elements.scroller.clientHeight + const top = Math.max(target, content - clientHeight, 0) + this.setSpacer(top === 0 ? 0 : Math.max(0, top + clientHeight - content)) + elements.scroller.scrollTop = top const metrics = scrollMetrics(elements.scroller) const landing = { metrics, position: null, turn: this.latestTurn } this.observation = { top: metrics.top, landing } - settled(landing) + reader.landedAtTail(landing) }, - }) + } + } + + /** + * Find the first visible top-level row at or below the scrollport top that holds no batch box. + * Binary search over the ordered top-level rows reads O(log n) boxes. + * @returns the reference row, or null when every visible row folds. + */ + private readingReference(): HTMLElement | null { + const elements = this.elements + if (elements === null) return null + const viewport = elements.scroller.getBoundingClientRect() + // Slot anchors use display:contents: their children are the top-level rows. + const rows = (elements.column.querySelector(':scope > [data-slot="conversation.chat.flow"]') ?? elements.column).children + let low = 0 + let high = rows.length + while (low < high) { + const middle = (low + high) >>> 1 + if ((rows[middle] as Element).getBoundingClientRect().bottom > viewport.top) high = middle + else low = middle + 1 + } + for (let index = low; index < rows.length; index++) { + const row = rows[index] as HTMLElement + if (row.hasAttribute('hidden')) continue + const rect = row.getBoundingClientRect() + if (rect.top >= viewport.bottom) return null + if (rect.height === 0 || rect.bottom <= viewport.top) continue + if (row.hasAttribute('data-chat-motion') || row.querySelector('[data-chat-motion]') !== null) continue + return row + } + return null } /** Clear fold reservations after a display-mode change, allowing the scroll range to shrink. */ diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index ce5ab755d1..d40f82715f 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -33,8 +33,8 @@ en: b25bf00ce3a6a119 zh: 0ab47a4014ea5e1e /chat-conversation-node-rules/display-modes/collapse-timing: - en: 03d744dd1c92bdd3 - zh: 19804eaf3234632a + en: ad74c1a610fc50d2 + zh: 13de05ffedf63f0c /chat-conversation-node-rules/display-modes/group-title-rules: en: 2392290662bc69e8 zh: 8872d651c07a8f4c diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index bda19aba52..2efd2681af 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -140,7 +140,7 @@ Settings → General → Collapse work details appears directly below Work detai With On next message, the latest exchange keeps its running presentation after completion: Compact and Standard retain their process headers, and Detailed retains its flat process rows. An exchange starts with the Turn whose first Step admits human input and includes every later Turn until the next such Turn, so a Turn woken by a notice (a settled subagent, goal round, schedule, or job) leaves the preceding Turns of its exchange expanded. A pending ordinary transcript submission or a Turn that opens a newer exchange ends this exemption without clearing the reader's manual whole-Turn choice; a bare Turn start does not, because the host claims the waking input before it logs Step 1. Inner disclosures reset only when their outer process actually hides. Queued input and steering alone do not end it. The latest exchange of a reopened Session also receives the exemption, starting at the first loaded Turn when the loaded window holds no opening Turn; manual whole-Turn choices still take precedence. Switching back to On completion ends the exemption immediately. -When a new message triggers a fold, process details close and the transcript follows the new message in one upward movement, without a second scroll after the fold. Off-screen steps do not add bottom space; only the space needed to preserve the visible movement remains when the retained answer and new message cannot fill it. Content added during the fold updates its remaining movement. Reopening every closing row cancels that movement and releases obsolete bottom space. A reading gesture interrupts automatic following; finishing the fold does not resume it. +When a new message triggers a fold, process details close in one movement with no second scroll after the fold. If this client sent the message, or the reader was following the tail, the transcript lands on the new message; otherwise, such as a message from another tab or a queued message delivered after the previous Turn, the content the reader is looking at stays still. Off-screen steps do not add bottom space; only the space needed to preserve the visible movement remains when the retained answer and new message cannot fill it. Content added during the fold updates its remaining movement. Reopening every closing row cancels that movement and releases obsolete bottom space. A reading gesture interrupts automatic following; finishing the fold does not resume it. Verbose retains its flat process rows under either timing. Failed, stopped, and interleaved-input Turns retain their existing whole-Turn eligibility rules; delayed folding can still postpone Detailed's conversion from flat rows to historical groups. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index 3eedbc8c9a..e121f62439 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -140,7 +140,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 选择「下次发送时收起」后,最新交互段完成时保留运行中的呈现:简洁和标准保留过程组头,详细保留平铺过程行。交互段从首个 Step 接收真人输入的轮次开始,包含之后的所有轮次,直到下一个这样的轮次,因此由通知(子 agent 完成、Goal Round、定时任务或后台作业)唤醒的轮次不会收起同一交互段里更早的轮次。普通 transcript 输入进入待发送状态,或出现开启更新交互段的轮次时,取消这项例外,但保留用户对整轮的手动开合选择;仅出现新轮次的开始不会取消,因为 Host 先认领唤醒输入,再记录 Step 1。只有外层过程实际隐藏时,才重置内部开合。排队输入和单独的 steering 不会取消例外。重新打开 Session 时,其最新交互段也享有例外,已加载窗口中没有开启轮次时从第一个已加载轮次算起;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。 -新消息触发折叠时,过程收起与跟随新消息合为一次向上运动,折叠结束后不再追加滚动。屏幕外的步骤不增加底部补白;只有保留的正文和新消息无法填满可见内容移动所需空间时,才保留必要差额。折叠期间新增的内容会更新剩余运动;重新展开全部正在收起的行时,取消该运动并释放失效的底部补白。阅读手势会中断自动跟随,折叠结束不会重新接管。 +新消息触发折叠时,过程收起为一次运动,折叠结束后不再追加滚动。消息由本客户端发出、或读者原本在跟尾时,对话落到新消息;其余情况(例如另一个标签页发来的消息,或上一轮结束后才投递的排队消息)下,读者正在看的内容保持不动。屏幕外的步骤不增加底部补白;只有保留的正文和新消息无法填满可见内容移动所需空间时,才保留必要差额。折叠期间新增的内容会更新剩余运动;重新展开全部正在收起的行时,取消该运动并释放失效的底部补白。阅读手势会中断自动跟随,折叠结束不会重新接管。 完全展开模式在两种时机下都保留平铺过程行。失败、停止和含插入输入的轮次沿用原有整轮折叠资格;延后折叠仍可推迟详细模式从平铺行转换为历史分组的时机。 diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index c449d30d89..6eced3135c 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -28,7 +28,7 @@ import type { QuotaNoticeInjected } from '@deepseek-ai/dsh-client-ui-chat/client import type { PerformanceUsageRowInjected } from '../src/client/settings/PerformanceUsageRow.tsx' import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts' import { ActivityPill, UsagePill } from '../src/client/chat/StatsPills.tsx' -import { createFlowMotion } from '../src/client/chat/flow-motion.ts' +import { createFlowMotion, type InputFoldDriver } from '../src/client/chat/flow-motion.ts' import { CHAT_FLOW_INJECT, CHAT_NODE_INJECT } from '../src/client/apply.ts' import { UserContent } from '../src/client/chat/UserContent.tsx' @@ -376,10 +376,11 @@ describe('Chat apply wiring', () => { type UseGroupAction = ReturnType type UseGroupHeaderAction = ReturnType const standard = { sessionId: SID } as Parameters[0] - const reserveFirst = vi.fn<(px: number) => void>() - const reserveSecond = vi.fn<(px: number) => void>() - const first = createFlowMotion(reserveFirst, () => {}) - const second = createFlowMotion(reserveSecond, () => {}) + // Each viewport asks only its own driver for a batch; null keeps the rows on CSS transitions. + const driverFirst = vi.fn<() => InputFoldDriver | null>(() => null) + const driverSecond = vi.fn<() => InputFoldDriver | null>(() => null) + const first = createFlowMotion(driverFirst, () => {}) + const second = createFlowMotion(driverSecond, () => {}) const useFirst = spec.inject.hooks.groupAction(standard, { motion: first }) const useSecond = spec.inject.hooks.groupAction(standard, { motion: second }) const useFirstHeader = spec.inject.hooks.groupHeaderAction(standard, { motion: first }) @@ -422,13 +423,13 @@ describe('Chat apply wiring', () => { expect(first.foldActive()).toBe(true) expect(second.foldActive()).toBe(false) await act(async () => { await Promise.resolve() }) - expect(reserveFirst).toHaveBeenCalledExactlyOnceWith(80) - expect(reserveSecond).not.toHaveBeenCalled() + expect(driverFirst).toHaveBeenCalledTimes(1) + expect(driverSecond).not.toHaveBeenCalled() view.rerender(rows(true, true)) expect(second.foldActive()).toBe(true) await act(async () => { await Promise.resolve() }) - expect(reserveFirst).toHaveBeenCalledExactlyOnceWith(80) - expect(reserveSecond).toHaveBeenCalledExactlyOnceWith(90) + expect(driverFirst).toHaveBeenCalledTimes(1) + expect(driverSecond).toHaveBeenCalledTimes(1) const finished = new Event('transitionend', { bubbles: true }) Object.defineProperty(finished, 'propertyName', { value: 'height' }) act(() => { firstRow.dispatchEvent(finished) }) diff --git a/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx b/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx index b212f91940..6eb4ac4520 100644 --- a/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx @@ -92,10 +92,14 @@ function mountScroll(deferCompletedTurns = true, initial: Partial { motion.collapse(row, () => { row.hidden = true }) }) }, + /** Run the fold's frame clock to its end (a row outside a batch finishes on its transition). */ finishFold: () => { const event = new Event('transitionend', { bubbles: true }) Object.defineProperty(event, 'propertyName', { value: 'height' }) - act(() => { row.dispatchEvent(event) }) + act(() => { + row.dispatchEvent(event) + vi.advanceTimersByTime(200) + }) }, } } @@ -369,6 +373,8 @@ describe('Chat scroll collapse timing', () => { expect(h.scrollTo).not.toHaveBeenCalled() expect(h.scroller.scrollTop).toBe(0) await act(async () => { await Promise.resolve() }) + // The folded row leaves the committed layout; the landing reads the actual content height. + h.grow(920) act(() => { vi.advanceTimersByTime(400) }) expect(h.scrollTo).not.toHaveBeenCalled() expect(h.scroller.scrollTop).toBe(520) @@ -476,6 +482,7 @@ describe('Chat scroll collapse timing', () => { h.resize() h.update({ order: ['streaming-change'], running: true }) if (newInput) h.update({ submissionId: 'second' }) + h.grow(920) h.finishFold() await act(async () => { await Promise.resolve() }) act(() => { vi.advanceTimersByTime(400) }) diff --git a/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx b/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx index 4d8cc20f45..ad337a426e 100644 --- a/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx +++ b/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx @@ -3,6 +3,7 @@ import { act, cleanup, render } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { ChatViewport } from '../src/client/chat/use-chat-viewport.ts' +import { ScrollFollow } from '../src/client/chat/use-scroll-follow.ts' import { useSearchableHidden } from '../src/client/chat/searchable-hidden.ts' beforeEach(() => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) }) @@ -20,10 +21,11 @@ function viewport() { document.body.append(list) const owner = new ChatViewport() owner.attach(list, column) - return { list, row, spacer, owner, dispose: () => { owner.detach(); list.remove() } } + const unbind = owner.bindReader({ follow: new ScrollFollow(false, 25), landedAtTail: vi.fn(), pending: () => false }) + return { list, row, spacer, owner, dispose: () => { unbind(); owner.detach(); list.remove() } } } -it('isolates fold anchoring and reservation between viewports', async () => { +it('isolates fold anchoring and batch room between viewports', async () => { const first = viewport() const second = viewport() try { diff --git a/packages/client/ui-chat/tests/flow-motion.client.spec.tsx b/packages/client/ui-chat/tests/flow-motion.client.spec.tsx index 5eb8c2f853..f2f2e4a6c1 100644 --- a/packages/client/ui-chat/tests/flow-motion.client.spec.tsx +++ b/packages/client/ui-chat/tests/flow-motion.client.spec.tsx @@ -5,21 +5,33 @@ import { StrictMode, useRef } from 'react' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { createFlowMotion, hiddenOrCollapsing, motionEnabled, useFlowHidden, useMotionHidden, - type FlowMotion, type FlowMotionRows, + type FlowMotion, type FlowMotionRows, type InputFoldBox, type InputFoldDriver, } from '../src/client/chat/flow-motion.ts' const ownedRows = new Set() const subscriptions = new Set<() => void>() let motion: FlowMotion -let reserve: Mock<(px: number) => void> +let driver: Mock<() => InputFoldDriver | null> let changed: Mock<(active: boolean) => void> beforeEach(() => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - reserve = vi.fn<(px: number) => void>() + // Without a driver, rows keep their CSS transitions; batch tests supply one. + driver = vi.fn<() => InputFoldDriver | null>(() => null) changed = vi.fn<(active: boolean) => void>() - motion = createFlowMotion(reserve, changed) + motion = createFlowMotion(driver, changed) }) + +function batchDriver(travelling: readonly HTMLElement[] = []) { + const calls = { + begin: vi.fn<(boxes: readonly InputFoldBox[]) => ReadonlySet>(() => new Set(travelling)), + prepare: vi.fn<(progress: number, cancelled: boolean) => void>(), + frame: vi.fn<(progress: number, cancelled: boolean) => number>(() => -20), + finish: vi.fn<(cancelled: boolean) => void>(), + } + driver.mockReturnValue(calls) + return calls +} afterEach(async () => { try { for (const unsubscribe of subscriptions) unsubscribe() @@ -104,7 +116,7 @@ describe('flow motion', () => { expect(motion.foldActive()).toBe(false) expect(vi.getTimerCount()).toBe(0) await Promise.resolve() - expect(reserve).not.toHaveBeenCalled() + expect(driver).not.toHaveBeenCalled() expectCleared(row) }) @@ -121,7 +133,7 @@ describe('flow motion', () => { expect(row.style.opacity).toBe('0') expect(row.style.height).toBe('0px') await Promise.resolve() - expect(reserve).toHaveBeenCalledExactlyOnceWith(80) + expect(driver).toHaveBeenCalledTimes(1) transitionEnd(row, 'opacity') transitionEnd(child, 'height') expect(done).not.toHaveBeenCalled() @@ -180,7 +192,7 @@ describe('flow motion', () => { expect(idle).toHaveBeenCalledTimes(1) }) - it('cancels before reserve delivery without hiding the row or retaining callbacks', async () => { + it('cancels before the batch starts without hiding the row or retaining callbacks', async () => { const row = rowWithHeight(80) const done = vi.fn() motion.collapse(row, done) @@ -190,14 +202,15 @@ describe('flow motion', () => { expect(hiddenOrCollapsing(row)).toBe(false) expectCleared(row) await Promise.resolve() - expect(reserve).not.toHaveBeenCalled() + expect(driver).not.toHaveBeenCalled() transitionEnd(row, 'height') vi.runAllTimers() expect(done).not.toHaveBeenCalled() expect(row.hasAttribute('hidden')).toBe(false) }) - it('reserves nested closing content once and subtracts newly revealed height', async () => { + it('drives non-nested closing and revealing boxes in document order on one batch', async () => { + const calls = batchDriver() const parent = rowWithHeight(180) const child = rowWithHeight(80, parent) const sibling = rowWithHeight(50) @@ -206,26 +219,276 @@ describe('flow motion', () => { motion.collapse(parent, vi.fn()) motion.collapse(sibling, vi.fn()) motion.reveal(opening) + // A fresh reveal has not grown yet when the batch measures it. + Object.defineProperty(opening, 'offsetHeight', { value: 0, configurable: true }) await Promise.resolve() - expect(reserve).toHaveBeenCalledExactlyOnceWith(200) + expect(calls.begin).toHaveBeenCalledTimes(1) + expect(calls.begin.mock.calls[0]?.[0].map(box => [box.element, box.height, box.closing])).toEqual([ + [parent, 180, true], [sibling, 50, true], [opening, 30, false], + ]) + expect(motion.inputActive()).toBe(true) }) - it('does not rewrite a reopened row after its input-fold ownership is cancelled', async () => { + it('leaves a reveal-only commit to its CSS transition', async () => { + batchDriver() + const row = rowWithHeight(30) + motion.reveal(row) + await Promise.resolve() + expect(driver).not.toHaveBeenCalled() + expect(row.dataset.chatMotion).toBe('reveal') + vi.runAllTimers() + }) + + it('lands a batch on its frame clock and notifies idle after the final geometry', async () => { vi.useFakeTimers() + const calls = batchDriver() + const row = rowWithHeight(180) + const order: string[] = [] + const done = vi.fn(() => { order.push('hidden'); row.setAttribute('hidden', '') }) + calls.finish.mockImplementation(() => { order.push('finish') }) + motion.collapse(row, done) + subscriptions.add(motion.onFoldIdle(() => { order.push('idle') })) + await Promise.resolve() + vi.advanceTimersByTime(96) + expect(row.style.translate).toBe('0 -20px') + expect(Number.parseFloat(row.style.height)).toBeLessThan(180) + vi.advanceTimersByTime(400) + expect(order).toEqual(['hidden', 'finish', 'idle']) + expect(calls.finish).toHaveBeenCalledExactlyOnceWith(false) + expect(changed.mock.calls).toEqual([[true], [false]]) + expect(motion.inputActive()).toBe(false) + expectCleared(row) + }) + + it('keeps a travelling box opaque and in the flow while it shrinks', async () => { + vi.useFakeTimers() + const row = rowWithHeight(180) + const nested = rowWithHeight(60, row) + batchDriver([row]) + motion.collapse(nested, vi.fn()) + motion.collapse(row, vi.fn()) + await Promise.resolve() + expect(nested.style.height).toBe('60px') + expect(nested.style.transition).toBe('none') + vi.advanceTimersByTime(96) + expect(row.style.opacity).toBe('1') + expect(row.style.translate).toBe('') + expect(nested.style.opacity).toBe('1') + vi.advanceTimersByTime(400) + }) + + it('freezes a box that starts travelling after the batch began', async () => { + vi.useFakeTimers() + const travelling = new Set() + const calls = batchDriver() + calls.begin.mockReturnValue(travelling) + const row = rowWithHeight(180) + const nested = rowWithHeight(60, row) + motion.collapse(nested, vi.fn()) + motion.collapse(row, vi.fn()) + await Promise.resolve() + vi.advanceTimersByTime(96) + expect(row.style.translate).toBe('0 -20px') + travelling.add(row) + vi.advanceTimersByTime(16) + expect(row.style.opacity).toBe('1') + expect(row.style.translate).toBe('') + expect(nested.style.height).toBe('60px') + expect(nested.style.transition).toBe('none') + vi.advanceTimersByTime(400) + }) + + it('reports a batch as input-owned while it is scheduled or running, not for reveals alone', async () => { + vi.useFakeTimers() + batchDriver() + const opening = rowWithHeight(30) + motion.reveal(opening) + expect(motion.inputActive()).toBe(false) + const row = rowWithHeight(180) + motion.collapse(row, vi.fn()) + expect(motion.inputActive()).toBe(true) + await Promise.resolve() + expect(motion.inputActive()).toBe(true) + vi.advanceTimersByTime(400) + expect(motion.inputActive()).toBe(false) + motion.resumeInput() + expect(motion.inputActive()).toBe(false) + }) + + it('does not report a batch when every closing row sits inside an ancestor that closes on its own', async () => { + const calls = batchDriver() + const parent = rowWithHeight(180) + // The ancestor's motion is not owned by this batch, so its descendants cannot start one. + parent.dataset.chatMotion = 'collapse' + const child = rowWithHeight(60, parent) + motion.collapse(child, vi.fn()) + expect(motion.inputActive()).toBe(false) + await Promise.resolve() + expect(calls.begin).not.toHaveBeenCalled() + motion.cancelMotion(child) + delete parent.dataset.chatMotion + }) + + it('holds a row that closes during a batch in place and drives it in the next batch', async () => { + vi.useFakeTimers() + const calls = batchDriver() + const first = rowWithHeight(180) + const idle = vi.fn() + motion.collapse(first, vi.fn()) + await Promise.resolve() + subscriptions.add(motion.onFoldIdle(idle)) + vi.advanceTimersByTime(32) + const late = rowWithHeight(60) + const lateDone = vi.fn() + motion.collapse(late, lateDone) + expect(late.style.transition).toBe('none') + expect(late.style.height).toBe('60px') + expect(late.style.opacity).toBe('') + await Promise.resolve() + expect(calls.begin).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(200) + expect(calls.begin).toHaveBeenCalledTimes(2) + expect(calls.begin.mock.calls[1]?.[0].map(box => box.element)).toEqual([late]) + expect(idle).not.toHaveBeenCalled() + expect(motion.inputActive()).toBe(true) + vi.advanceTimersByTime(400) + expect(lateDone).toHaveBeenCalledTimes(1) + expect(idle).toHaveBeenCalledTimes(1) + expect(calls.finish).toHaveBeenCalledTimes(2) + }) + + it('finishes a held row at once when the next flush has no driver', async () => { + vi.useFakeTimers() + batchDriver() + const first = rowWithHeight(180) + motion.collapse(first, vi.fn()) + await Promise.resolve() + vi.advanceTimersByTime(32) + const late = rowWithHeight(60) + const lateDone = vi.fn() + motion.collapse(late, lateDone) + driver.mockReturnValue(null) + vi.advanceTimersByTime(400) + expect(lateDone).toHaveBeenCalledTimes(1) + expectCleared(late) + }) + + it('takes over a closing row at its current size and finishes one that no longer has any', async () => { + vi.useFakeTimers() + const moving = rowWithHeight(180) + motion.collapse(moving, vi.fn()) + await Promise.resolve() + // No driver yet: the row closes on its own transition and shrinks to 120 before a batch takes it over. + Object.defineProperty(moving, 'offsetHeight', { value: 120, configurable: true }) + const calls = batchDriver() + const gone = rowWithHeight(60) + const goneDone = vi.fn() + motion.collapse(gone, goneDone) + // Before the batch starts, the row's ancestor finished closing, so the row has no size left. + Object.defineProperty(gone, 'offsetHeight', { value: 0, configurable: true }) + expect(goneDone).not.toHaveBeenCalled() + await Promise.resolve() + expect(goneDone).toHaveBeenCalledTimes(1) + expectCleared(gone) + expect(calls.begin.mock.calls[0]?.[0].map(box => [box.element, box.height])).toEqual([[moving, 120]]) + vi.advanceTimersByTime(400) + }) + + it('counts only the remaining growth of a reveal a batch takes over mid-transition', async () => { + vi.useFakeTimers() + const opening = rowWithHeight(120) + motion.reveal(opening) + await Promise.resolve() + // The reveal ran alone on its own transition and reached 45px before a closing row starts a batch. + Object.defineProperty(opening, 'offsetHeight', { value: 45, configurable: true }) + const calls = batchDriver() + const closing = rowWithHeight(80) + motion.collapse(closing, vi.fn()) + await Promise.resolve() + expect(calls.begin.mock.calls[0]?.[0].map(box => [box.element, box.height, box.closing])).toEqual([ + [opening, 75, false], [closing, 80, true], + ]) + expect(opening.style.height).toBe('45px') + vi.advanceTimersByTime(400) + }) + + it('holds a row revealed during a batch and starts its own transition when no batch follows', async () => { + vi.useFakeTimers() + batchDriver() + const closing = rowWithHeight(180) + motion.collapse(closing, vi.fn()) + await Promise.resolve() + vi.advanceTimersByTime(32) + const opening = rowWithHeight(40) + motion.reveal(opening) + expect(opening.style.transition).toBe('none') + expect(opening.style.height).toBe('0px') + vi.advanceTimersByTime(200) + expect(opening.style.transition).toContain('height') + expect(opening.style.height).toBe('40px') + vi.runAllTimers() + expectCleared(opening) + }) + + it('reports reader cancellation to the driver and lets a newer request resume the target', async () => { + vi.useFakeTimers() + const calls = batchDriver() + const row = rowWithHeight(180) + motion.cancelInput() + motion.collapse(row, vi.fn()) + await Promise.resolve() + motion.cancelInput() + vi.advanceTimersByTime(96) + expect(calls.frame.mock.calls.at(-1)?.[1]).toBe(true) + motion.resumeInput() + vi.advanceTimersByTime(16) + expect(calls.frame.mock.calls.at(-1)?.[1]).toBe(false) + motion.cancelInput() + vi.advanceTimersByTime(400) + expect(calls.finish).toHaveBeenCalledExactlyOnceWith(true) + }) + + it('finishes a batch synchronously under reduced motion', async () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) + const calls = batchDriver() + const row = rowWithHeight(180) + const done = vi.fn() + motion.collapse(row, done) + await Promise.resolve() + expect(done).toHaveBeenCalledTimes(1) + expect(calls.finish).toHaveBeenCalledExactlyOnceWith(false) + }) + + it('does not rewrite a reopened row after its batch is cancelled', async () => { + vi.useFakeTimers() + const calls = batchDriver() const row = rowWithHeight(180) const done = vi.fn() motion.collapse(row, done) - motion.followInput({ begin: vi.fn(), frame: () => -20, finish: vi.fn() }) await Promise.resolve() vi.advanceTimersByTime(96) motion.cancelMotion(row) expectCleared(row) + expect(calls.finish).toHaveBeenCalledExactlyOnceWith(true) vi.advanceTimersByTime(400) expectCleared(row) expect(row.style.translate).toBe('') expect(done).not.toHaveBeenCalled() }) + it('cancels a running batch with its owner', async () => { + vi.useFakeTimers() + const calls = batchDriver() + const row = rowWithHeight(180) + motion.collapse(row, vi.fn()) + await Promise.resolve() + vi.advanceTimersByTime(32) + motion.clear() + expect(calls.finish).toHaveBeenCalledExactlyOnceWith(true) + expect(motion.inputActive()).toBe(false) + expectCleared(row) + }) + it('clears pending reservations and idle work with its owner', async () => { const row = rowWithHeight(80) const idle = vi.fn() @@ -233,7 +496,7 @@ describe('flow motion', () => { motion.onFoldIdle(idle) motion.clear() await Promise.resolve() - expect(reserve).not.toHaveBeenCalled() + expect(driver).not.toHaveBeenCalled() expect(idle).not.toHaveBeenCalled() expectCleared(row) }) @@ -292,7 +555,7 @@ describe('flow motion', () => { act(() => { vi.advanceTimersByTime(100) }) view.rerender(
) expect(changed.mock.calls).toEqual([[true]]) - expect(reserve).toHaveBeenCalledExactlyOnceWith(80) + expect(driver).toHaveBeenCalledTimes(1) act(() => { vi.advanceTimersByTime(140) }) expect(row.getAttribute('hidden')).toBe('until-found') expect(changed.mock.calls).toEqual([[true], [false]]) @@ -347,7 +610,7 @@ describe.each([ expect(motion.foldActive()).toBe(false) expectCleared(row) await Promise.resolve() - expect(reserve).not.toHaveBeenCalled() + expect(driver).not.toHaveBeenCalled() act(() => { vi.runAllTimers() }) transitionEnd(row, 'height') expect(row.hasAttribute('hidden')).toBe(false) diff --git a/packages/client/ui-chat/tests/fold-follow.client.spec.ts b/packages/client/ui-chat/tests/fold-follow.client.spec.ts index 08eab35fcd..19a3dea326 100644 --- a/packages/client/ui-chat/tests/fold-follow.client.spec.ts +++ b/packages/client/ui-chat/tests/fold-follow.client.spec.ts @@ -20,7 +20,41 @@ afterEach(async () => { } }) -function fixture() { +const frames = new Map() +let frameClock = 0 +let nextFrame = 0 + +/** Run a fixed number of queued animation frames. */ +function runFrameCount(count: number): void { + for (let index = 0; index < count && frames.size > 0; index++) { + const pending = [...frames.values()] + frames.clear() + frameClock += 16 + for (const callback of pending) callback(frameClock) + } +} + +/** Run queued animation frames until the batch stops requesting more. */ +function runFrames(): void { + for (let guard = 0; frames.size > 0 && guard < 100; guard++) { + const pending = [...frames.values()] + frames.clear() + frameClock += 16 + for (const callback of pending) callback(frameClock) + } +} + +/** + * Earlier content, one closing row, and later content in a 400px scrollport. + * @param layout - heights above and below the row and the starting scroll position. + */ +function fixture(layout: { earlier: number; later: number; top: number; following: boolean }) { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = ++nextFrame + frames.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { frames.delete(id) }) const list = document.createElement('div') const column = document.createElement('div') const row = document.createElement('div') @@ -29,8 +63,8 @@ function fixture() { column.append(row) list.append(column, spacer) document.body.append(list) - let contentHeight = 2_400 - let top = 2_000 + let contentHeight = layout.earlier + 1_800 + layout.later + let top = layout.top const room = () => Number.parseFloat(spacer.style.height) || 0 const floor = () => Math.max(0, contentHeight + room() - 400) const scrollTo = vi.fn((options: ScrollToOptions) => { @@ -45,14 +79,20 @@ function fixture() { set: (value: number) => { top = Math.max(0, Math.min(value, floor())) }, }, scrollTo: { value: scrollTo }, + getBoundingClientRect: { value: () => ({ top: 0, bottom: 400, height: 400 }) }, + }) + Object.defineProperty(column, 'offsetHeight', { get: () => contentHeight }) + Object.defineProperties(row, { + offsetHeight: { value: 1_800 }, + getBoundingClientRect: { value: () => ({ top: layout.earlier - top, bottom: layout.earlier - top + 1_800, height: 1_800 }) }, }) - Object.defineProperty(row, 'offsetHeight', { value: 1_800 }) const viewport = new ChatViewport() viewport.attach(list, column) - const follow = new ScrollFollow(true, 25) + const follow = new ScrollFollow(layout.following, 25) const reading = new ChatReading(viewport, { read: () => null, save: vi.fn() }, { - initialized: true, followingTail: true, activeTurn: 1, + initialized: true, followingTail: layout.following, activeTurn: 1, }, vi.fn(), follow) + const unbind = reading.bindFolds() viewport.connect({ scroll: reading.onScroll, scrollEnd: reading.onScrollEnd, @@ -61,21 +101,22 @@ function fixture() { intent: () => { reading.interruptFollow() }, }) disposers.add(() => { + unbind() reading.dispose() viewport.detach() list.remove() + frames.clear() }) return { list, spacer, row, viewport, follow, reading, scrollTo, grow: (height: number) => { contentHeight = height }, close: async () => { - viewport.motion.collapse(row, () => { row.hidden = true }) + viewport.motion.collapse(row, () => { + row.hidden = true + contentHeight -= 1_800 + }) await Promise.resolve() - contentHeight -= 1_800 - expect(list.scrollTop).toBe(2_000) - const event = new Event('transitionend') - Object.defineProperty(event, 'propertyName', { value: 'height' }) - row.dispatchEvent(event) + runFrames() }, scroll: (value: number) => { list.scrollTop = value @@ -84,59 +125,103 @@ function fixture() { } } -it('retains fold room and lets content growth consume it without reversing the scrollport', async () => { - const h = fixture() +/** The row is partly on screen: content above the reading line never slides back, so room remains. */ +const VISIBLE_ROW = { earlier: 600, later: 0, top: 2_000, following: true } + +it('lands a following reader at the tail and lets content growth consume the room it keeps', async () => { + const h = fixture(VISIBLE_ROW) await h.close() - h.viewport.reclaimBelow() - h.reading.followTail('smooth') - expect(h.list.scrollTop).toBe(2_000) - expect(h.spacer.style.height).toBe('1800px') + expect(h.list.scrollTop).toBe(600) + expect(h.spacer.style.height).toBe('400px') + expect(h.reading.followingTail).toBe(true) expect(h.scrollTo).not.toHaveBeenCalled() + h.grow(800) + h.viewport.reclaimBelow() + h.reading.onResize() + expect(h.list.scrollTop).toBe(600) + expect(h.spacer.style.height).toBe('200px') h.grow(1_000) h.viewport.reclaimBelow() h.reading.onResize() - expect(h.list.scrollTop).toBe(2_000) - expect(h.spacer.style.height).toBe('1400px') - h.grow(2_400) - h.viewport.reclaimBelow() - h.reading.onResize() - expect(h.list.scrollTop).toBe(2_000) + expect(h.list.scrollTop).toBe(600) expect(h.spacer.style.height).toBe('') - h.grow(2_520) + h.grow(1_120) h.reading.onResize() - expect(h.list.scrollTop).toBe(2_120) + expect(h.list.scrollTop).toBe(720) expect(h.follow.animating).toBe(false) }) +it('adds a reader\'s own scrolling during the fold to the position it keeps still', async () => { + const h = fixture({ earlier: 100, later: 500, top: 2_000, following: false }) + h.viewport.motion.collapse(h.row, () => { h.row.hidden = true; h.grow(600) }) + await Promise.resolve() + runFrameCount(6) + h.list.scrollTop -= 50 + runFrames() + expect(h.list.scrollTop).toBe(150) + expect(h.reading.followingTail).toBe(false) +}) + +it('keeps the reader landing while reader movement awaits sampling', async () => { + const h = fixture({ earlier: 100, later: 500, top: 2_000, following: true }) + h.list.dispatchEvent(new Event('wheel')) + h.scroll(1_990) + expect(h.reading.pending).toBe(true) + h.viewport.motion.collapse(h.row, () => { h.row.hidden = true; h.grow(600) }) + await Promise.resolve() + runFrames() + expect(h.list.scrollTop).toBe(190) + vi.advanceTimersByTime(500) +}) + +it('lands a tail batch on the committed content when it reaches past the estimate', async () => { + const h = fixture(VISIBLE_ROW) + // Content arriving with the hidden commit is not in the estimate; the landing reads the real floor. + h.viewport.motion.collapse(h.row, () => { h.row.hidden = true; h.grow(1_200) }) + await Promise.resolve() + runFrames() + expect(h.list.scrollTop).toBe(800) + expect(h.spacer.style.height).toBe('') + expect(h.reading.followingTail).toBe(true) +}) + +it('keeps a reader below folding rows on the same content without room or follow', async () => { + const h = fixture({ earlier: 100, later: 500, top: 2_000, following: false }) + await h.close() + expect(h.list.scrollTop).toBe(200) + expect(h.spacer.style.height).toBe('') + expect(h.reading.followingTail).toBe(false) + expect(h.reading.pending).toBe(false) +}) + it('keeps manual off-bottom ownership and reclaims only space below the reader', async () => { - const h = fixture() + const h = fixture(VISIBLE_ROW) await h.close() - h.reading.followTail('smooth') - h.scroll(1_000) h.list.dispatchEvent(new Event('wheel')) - h.scroll(900) + h.scroll(300) vi.advanceTimersByTime(500) expect(h.reading.followingTail).toBe(false) - expect(h.spacer.style.height).toBe('700px') + expect(h.spacer.style.height).toBe('100px') h.viewport.reclaimBelow() h.reading.onResize() - expect(h.list.scrollTop).toBe(900) - expect(h.spacer.style.height).toBe('700px') + runFrames() + expect(h.list.scrollTop).toBe(300) + expect(h.spacer.style.height).toBe('100px') expect(h.follow.animating).toBe(false) }) -it('retains existing fold room when reduced-motion following has no forward distance', async () => { - const h = fixture() - await h.close() +it('lands immediately under reduced motion', async () => { vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) - h.reading.followTail('smooth') - expect(h.list.scrollTop).toBe(2_000) - expect(h.spacer.style.height).toBe('1800px') + const h = fixture(VISIBLE_ROW) + h.viewport.motion.collapse(h.row, () => { h.row.hidden = true; h.grow(600) }) + await Promise.resolve() + expect(h.list.scrollTop).toBe(600) + expect(h.spacer.style.height).toBe('400px') expect(h.follow.animating).toBe(false) }) it.each([1_999.5, 2_000, 2_000.5])('restores follow immediately at a rounded floor sample (%s)', (top) => { - const h = fixture() + const h = fixture(VISIBLE_ROW) h.reading.pauseFollowing() h.reading.onScroll({ metrics: { top, floor: 2_000, height: 400 }, movedByReader: true }) expect(h.reading.followingTail).toBe(true) @@ -144,7 +229,7 @@ it.each([1_999.5, 2_000, 2_000.5])('restores follow immediately at a rounded flo }) it.each([1_999, 2_001])('keeps movement at least one pixel from the floor pending (%s)', (top) => { - const h = fixture() + const h = fixture(VISIBLE_ROW) h.reading.pauseFollowing() h.reading.onScroll({ metrics: { top, floor: 2_000, height: 400 }, movedByReader: true }) expect(h.reading.followingTail).toBe(false) @@ -152,7 +237,7 @@ it.each([1_999, 2_001])('keeps movement at least one pixel from the floor pendin }) it('does not measure geometry to interrupt an idle follow controller', () => { - const h = fixture() + const h = fixture(VISIBLE_ROW) const reads = (['clientHeight', 'scrollHeight', 'scrollTop'] as const).map(property => vi.spyOn(h.list, property, 'get')) h.viewport.interruptFollow(h.follow) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 7713ce057f..de1c5e1f59 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -21,8 +21,8 @@ en: 9912b286384c41d7 zh: 3e994887c1948677 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: ddabf2f192114e62 - zh: 31370ed6d5149409 + en: aab84874b0a00ea5 + zh: 642bacaf76385e83 /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: bb1244310f5c0382 zh: 824b84077432b559 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 11c7490a7f..627a1872ae 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -102,7 +102,7 @@ Clicking unused space inside an editable composer preserves its current caret or Each composer attempt carries its original occurrence time, gesture, delivery intent, and Session model/run-state snapshot through asynchronous command arbitration. The ordinary-message notification fires once before reference serialization, including attachment-only submissions; handled or claimed commands do not notify. Snapshot and notification failures cannot block sending. -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 accept pasted and dropped PNG, JPEG, WebP, and GIF images with the same projected limits; a batch containing a generic file, unsupported image format, or directory is refused before intake. They 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 accept pasted and dropped PNG, JPEG, WebP, and GIF images with the same projected limits; a batch containing a generic file, unsupported image format, or directory is refused before intake. Their sends register the same local echo; the continuation request carries the echo's `requestId`, which the Host records as the child message's `rpcId`. 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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index cf6a73df3b..66e5a2865b 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -102,7 +102,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 每次输入框提交都携带原始发生时间、操作来源、投递意图以及会话模型和运行状态快照,贯穿异步命令裁决。普通消息通知在引用序列化前发出一次,纯附件提交也遵循这一规则;已处理或认领的命令不发出通知。快照读取与通知消费异常不能阻断发送。 -默认发送采用乐观提交: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 子代理允许粘贴和拖入 PNG、JPEG、WebP、GIF 图片,并沿用相同的投影限制;包含通用文件、不支持的图片格式或目录的批次会在进入草稿前整批拒绝。它们不创建本地回显,因为其 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 子代理允许粘贴和拖入 PNG、JPEG、WebP、GIF 图片,并沿用相同的投影限制;包含通用文件、不支持的图片格式或目录的批次会在进入草稿前整批拒绝。它们的发送同样注册本地回显;continuation 请求携带回显的 `requestId`,Host 把它记录为子会话消息的 `rpcId`。 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 2ef2309ccf..fc76701be6 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -261,13 +261,6 @@ export class ConversationController extends Service implements IConversation { ? { type: 'image' as const, ...await this.encodeImage(attachment.file) } : { type: 'file' as const, receiptId: uploadFor(attachment).receiptId }), ) - const snapshot = session.getSnapshot() - if (snapshot.subagent !== null) { - const uploaded = await serializeAttachments() - const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] - const result = await session.prompt(content, mode, signal) - return result.ok ? { kind: 'success' } : { kind: 'error' } - } let finishRetirement: ((retirement: PendingSubmissionRetirement) => void) | undefined const retirement = attachments.length === 0 ? undefined diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 6665284ae4..d784e37b90 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -755,7 +755,7 @@ describe('sendSession submission echo', () => { await b.runtime.dispose() }) - it('sends a subagent continuation without registering an unobservable echo', async () => { + it('echoes a subagent continuation and sends it under the echo request id', async () => { const b = await bench() const session = b.runtime.sessions.binding('s1')!.session const snapshot = session.getSnapshot() @@ -768,8 +768,11 @@ describe('sendSession submission echo', () => { }) const prompt = vi.spyOn(session, 'prompt').mockResolvedValue({ ok: true, value: { accepted: true } }) await expect(b.root.sendSession(session, '继续', [], 'queue')).resolves.toEqual({ kind: 'success' }) - expect(beginSubmission).not.toHaveBeenCalled() - expect(prompt).toHaveBeenCalledWith([{ type: 'text', text: '继续' }], 'queue', undefined) + expect(beginSubmission).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ mode: 'queue', text: '继续' })) + const echo = beginSubmission.mock.results[0] + const requestId = echo?.type === 'return' ? echo.value.requestId : undefined + expect(requestId).toEqual(expect.any(String)) + expect(prompt).toHaveBeenCalledWith([{ type: 'text', text: '继续' }], 'queue', undefined, requestId) await b.runtime.dispose() }) }) diff --git a/tsconfig.host.json b/tsconfig.host.json index ec1bcc2316..cd77f78880 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -10,6 +10,7 @@ "include": [ "apps/web/tests/collapse-timing.e2e.ts", "apps/web/tests/notice-wake-tail.e2e.ts", + "apps/web/tests/fold-blank-matrix.e2e.ts", "apps/web/tests/chat-flow-layout.e2e.ts", "apps/web/product-isolation.ts", "apps/web/katex-fonts.ts",