One fold motion for collapsing on the next message
「下条消息时收起」只用一条折叠动画
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 bott
English
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):
- 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); the table above covers the human input that still folded through the generic path. The data comes from 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 hands every batch of closing rows to the InputFoldDriver that the viewport supplies, and 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
reservecallback, thependingReserveandpendingGrowthreservation arithmetic,reserveBelow, and the post-foldfollowAfterFold.reclaimBelow(returning room after new content or reader scrolling) andresetBelow(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 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.
中文
Problem
「何时收起工作详情」选「下条消息时收起」时,上一个交互段的过程行在下一条人类输入到来时收起。滚动层原本有两条折叠路径,按 ownInput(use-chat-scroll.ts)二选一:
- 输入折叠路径:折叠开始时测量收起行,算出最终落点和所需的底部补白,收起与滚动在同一个帧时钟上推进,一次落定。
- 通用路径:scrollTop 不动,把收起的高度整块预留为底部补白,折叠结束后再按跟随状态补滚一次。它只对折叠发生在读者下方的情况成立。折叠区在读者上方时,读者正在看的内容整体上移,视口里只剩补白。
ownInput 只在三种信号下成立:对话流里的本地回显(submissionId)、steer 回显(steeringId)、列表最后一行是用户消息(lastIsUser)。Host 写回开场输入的那次提交里,该轮的过程控制行排在输入之后,lastIsUser 不成立。于是,凡是不经过本页对话流回显的人类输入都走通用路径:
| 来源 | 本页回显 | 读者在底部 | 读者在正文中段 |
|---|---|---|---|
| 主会话输入框空闲发送 | 有,在对话流 | 正常 | 正常 |
| 主会话排队消息在上一轮结束后自动投递 | 有,但在排队区,不参与判定 | 整屏空白 | 整屏空白 |
| 另一个标签页发送 | 无 | 整屏空白 | 整屏空白 |
| 右侧栏子会话输入框发送 | 无(composer 跳过 beginSubmission) | 整屏空白 | 整屏空白 |
| 子会话里父消息唤醒多轮后人类发送 | 无 | 整屏空白(补白约 6000 px) | — |
通知唤醒的 Turn 已不再触发折叠(分组树位置与交互段);上表是仍经由通用路径折叠的人类输入。数据来自 fold-blank-matrix.e2e.ts。
子会话不显示本地回显,是因为引入回显时 subagents.prompt 不携带 request id,回显无法与子会话写回的消息对应。此后子 agent 控制迁到 Remote,SubagentPromptRequest.requestId 已被 Host 写入子会话用户消息的 source.rpcId;客户端 Session.prompt 却仍另行生成 id。
Decision
「下条消息时收起」只保留一条折叠动画:flow-motion.ts 把每一批收起交给 viewport 提供的 InputFoldDriver,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 覆盖主会话 19 例与右侧栏子会话 8 例,维度包括读者位置(底部、正文中段、正文开头、步骤中间、上一轮)、输入来源(输入框、回合在写入消息前失败、另一个标签页、排队投递、steer、父消息、一次折叠多轮)、正文长短,以及 Standard 与「完成时收起」对照。布局空洞指视口顶部到输入框之间没有任何可见行的盒子覆盖的最大连续高度:落定后不超过 120 px,动画中至多 1 帧超过。它按盒子计算,已经淡出但尚未收完的收起行仍算作覆盖。「到底部」的用例断言落到底部;「内容不动」的用例断言读者锚定行的屏幕位置变化不超过 2 px。正文很短的用例只断言回复到达后不再留空。
Alternatives considered
保留通用路径,只修自己输入的识别。 两条路径的几何规则继续分叉;非自己输入时,中段读者要么被拉到底部,要么仍然看到空白。
通用路径改为按读者位置补偿,但与输入路径分开实现。 补偿与补白的几何代码重复一份,两边容易再次不一致。
用开启交互段的人类输入节点识别自己输入,子会话不补回显。 另一个标签页的输入会被当成自己输入,把中段读者拉到底部;子会话发送后仍然不能立即看到自己的消息。
允许内容回滑,以填满短正文留下的差额。 同一次动画里内容先上后下。
Consequences
- 收益:整屏空白的来源(通用路径)不再存在;落点只看「是不是自己的输入」和「读者是否在跟随」,与消息从哪个页面、何时投递无关。子会话发送立即显示自己的消息。
- 代价:正文很短时,「内容只上不下」仍会留出差额,直到新内容填满。
- 焦点停在正在收起的行里时,焦点保护会让该行保持展开。若它是被封顶高度的过程组,正文会立即缩到封顶高度。这次缩小发生在批次开始之前,「读者内容不动」的落点测不到,读者内容会移动一次。