Skip to content

Commit b6f2ab4

Browse files
fengzhi09s39-dev
andauthored
fix(webui): the transcript follows the stream, and only the reader moves it (#129)
Nothing moved the scroll container while a turn streamed. The whole app had one auto-scroll path and it hung off a button, so a 15-second turn left scrollTop at 0 while scrollHeight went 688 -> 1196: the text was rendering the whole time, entirely below the fold. "It only shows up when the turn finishes" was the same mechanism — the activity group holds itself open during the turn and collapses to a summary line at the end, finally making room. Virtualization was not involved: the turn peaked at 8 units against a threshold of 200, so the window covered the whole array. isNearBottom and decideScrollBehavior existed and were never called. The container's scrollTop has three legitimate writers — follow, the reader, and restore — and the browser's scroll anchoring was a fourth, implicit one. Anchoring is off now, and "has the reader scrolled away" compares against the position we last pinned rather than re-measuring distance from the bottom, which gets the wrong answer while the transcript is still growing under a reader who never scrolled. Co-authored-by: s39-dev <s39-dev@local>
1 parent b35cdf8 commit b6f2ab4

8 files changed

Lines changed: 670 additions & 22 deletions

File tree

‎docs/webui.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1373,6 +1373,35 @@ thinking-level streaming marker (the `▍` cursor only marks the trailing
13731373
assistant block), so this is the most honest signal the render layer can
13741374
derive.
13751375

1376+
### Streaming tail visibility (webui-parity ticket 88)
1377+
1378+
**Invariant: while a turn streams, the tail of the transcript is inside the
1379+
scroller's visible box.** Every token the engine emits must be readable without
1380+
the reader scrolling. A turn appends a block and then grows it token by token,
1381+
so the DOM below the reader grows while their `scrollTop` does not move; with
1382+
nothing pinning the offset, the answer is laid out below the fold and the reader
1383+
watches the thinking indicator for the whole turn. Reproduced on the unfixed
1384+
build: over a 15-second turn `scrollTop` never left 0 while `scrollHeight` went
1385+
688 → 1196.
1386+
1387+
This is not virtualization. Below `VIRTUAL_LIST_THRESHOLD` (200 units) the
1388+
window is `useVirtual: false` and every unit is in the DOM; the measured turn
1389+
peaked at 8 units. The tail was rendered and simply off-screen.
1390+
1391+
| Concern | Decision | Rejected alternative |
1392+
| --- | --- | --- |
1393+
| Who owns the offset | Exactly three: the tail follow, the reader, the persisted-position restore. Browser scroll anchoring is switched OFF on the scroller (`.chat-scroll`, `app/globals.css`) because it is a fourth, implicit owner — it adjusted `scrollTop` by 55 → 125 px on its own while the transcript grew above the viewport, which the follow reads as the reader leaving | Leaving anchoring on. A transcript that grows at the tail needs pinning, not anchoring; with it on the follow cannot attribute a move to its cause |
1394+
| When the follow turns off | When the container moves off the pin the follow itself last wrote (`isAwayFromPin`, 2 px tolerance). Content is appended BELOW the reader, so growth alone never changes `scrollTop` | A nearness test (`isNearBottom`) at scroll-event time. Scroll events dispatch asynchronously, so by the time the handler runs the next SSE frame may already have grown the transcript and the test answers "no" for a reader who never left. Shipped once, measured latching off ~20 s into every turn, replaced |
1395+
| When the follow re-arms | The reader scrolls back to the tail, or clicks the "jump to latest" pill (which calls `followNow()` — the pill's own scroll is about to change the position a re-arm would read) | Re-deriving from metrics every commit, which cannot tell "the reader moved" from "the transcript grew" |
1396+
| Reader scrolled up | Left exactly where they are, for the rest of the turn; the pill stays available. Measured: 0 px drift across 157 samples / 40 s of continued streaming | Following anyway, on the argument that the reader will want the answer eventually. Dragging a reader who is reading history is the other half of the same annoyance |
1397+
| While a turn is live | The persisted-position restore is gated off (`sessionRunning`). `initialScrollTop` is re-read from storage on every render, so the restore re-armed itself from the follow's own persisted writes and dragged the container back one frame after each pin | Letting both write. Two owners for one offset is the defect, not the fix |
1398+
| Animation | Instant, not smooth — a smooth scroll chases a target that moves with every token, so it lags the stream and overshoots at turn end | `behavior: "smooth"` on the pin |
1399+
| Virtualization | Untouched. The follow is orthogonal: one number written per commit, before paint, in a layout effect | Disabling virtualization above 200 units to keep the tail rendered. The tail was always rendered |
1400+
1401+
The restore is unaffected for a settled session — that is the case it exists
1402+
for. Reopening a session left on the tail lands on the tail; one left
1403+
mid-history lands mid-history.
1404+
13761405
### Tool card (ticket 46, PR3)
13771406

13781407
One tool call renders as a native `<details>` card (`ToolCard`), on the

‎docs/webui.zh-CN.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,32 @@ loading-states 相同:让 SSR 渲染测试可以脱离 `chat.tsx` 的 `@/` 别
986986
流式标记(`▍` 光标只标在尾部 assistant 块上),这是渲染层能拿到的
987987
最诚实信号。
988988

989+
### 流式期间尾部内容必须可见(webui-parity 工单 88)
990+
991+
**不变式:回合流式输出期间,转录的尾部始终在滚动容器的可视区内。**
992+
引擎吐出的每一个 token 都应当在不滚动的前提下读到。回合先追加一个块,
993+
再逐 token 把它撑大,于是读者下方的 DOM 在长、`scrollTop` 却不动;没有
994+
任何东西钉住这个偏移,答案就被排在折叠线以下,读者整个回合只能盯着
995+
「思考中」。在未修复的版本上实测:一个 15 秒的回合里 `scrollTop` 始终
996+
为 0,而 `scrollHeight` 从 688 涨到 1196。
997+
998+
这与虚拟化无关。低于 `VIRTUAL_LIST_THRESHOLD`(200 个单元)时窗口是
999+
`useVirtual: false`,每个单元都在 DOM 里;实测那个回合峰值只有 8 个
1000+
单元。尾部一直渲染着,只是落在屏幕外。
1001+
1002+
| 关切点 | 决策 | 被否掉的方案 |
1003+
| --- | --- | --- |
1004+
| 偏移归谁管 | 只有三方:尾部跟随、读者本人、滚动位置持久化还原。浏览器自带的滚动锚定(scroll anchoring)在滚动容器上被**关掉**(`app/globals.css` 的 `.chat-scroll`),因为它是隐式的第四方——转录在视口上方增长时它自行把 `scrollTop` 从 55 挪到 125,而跟随会把这读成「读者离开了」 | 保留锚定。尾部增长的转录需要的是钉住而不是锚定;留着它,跟随就无法归因任何一次位移 |
1005+
| 跟随何时关闭 | 当容器偏离跟随自己上一次钉下的位置时(`isAwayFromPin`,2 px 容差)。内容追加在读者**下方**,所以单是增长永远不会改变 `scrollTop` | 在 scroll 事件回调里做「是否靠近底部」判定。scroll 事件是异步派发的,等回调跑起来时下一个 SSE 帧可能已经把转录撑大了,于是对根本没离开的读者也答「否」。这个版本上线过,实测每个回合约 20 秒后跟随自己关掉,已替换 |
1006+
| 跟随何时恢复 | 读者自己滚回尾部,或点「跳到最新」按钮(该按钮调 `followNow()`——它自己的滚动会立刻改掉「恢复」本该读的那个位置) | 每次提交都从度量重新推导——那样分不清「读者动了」和「转录长了」 |
1007+
| 读者已上翻 | 原地不动,直到回合结束;按钮继续可用。实测:连续流式输出 40 秒、157 次采样,漂移 0 px | 照样跟随,理由是「读者终究会想要答案」。把正在读历史的读者拽走是同一个毛病的另一半 |
1008+
| 回合进行中 | 持久化位置的还原被 `sessionRunning` 门控关掉。`initialScrollTop` 每帧都从存储重读,于是还原会被跟随自己写下去的值重新武装,在每次钉住后一帧把容器拽回去 | 两边都写。同一个偏移有两个主人就是缺陷本身,不是修复 |
1009+
| 动画 | 直接跳,不用平滑滚动——平滑滚动追的是一个随每个 token 移动的目标,会落后于流式输出,并在回合结束时过冲 | 钉住时用 `behavior: "smooth"` |
1010+
| 虚拟化 | 不动。两者正交:每次提交在 layout effect 里写一个数,都在绘制之前 | 超过 200 个单元就关掉虚拟化以保证尾部被渲染。尾部一直都被渲染 |
1011+
1012+
对已结束的会话,还原逻辑不受影响——那正是它存在的场景。留在尾部的会话
1013+
重开仍在尾部,停在历史中段的会话重开仍在中段。
1014+
9891015
### 工具卡片(工单 46 PR3)
9901016

9911017
单次工具调用渲染为一张原生 `<details>` 卡片(`ToolCard`),结构照

‎packages/webui/webapp/app/globals.css‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,37 @@ details.webui-thinking-block > summary::-webkit-details-marker {
410410
}
411411
}
412412

413+
/* Scroll anchoring off on the chat scroller (webui-parity 88).
414+
*
415+
* `overflow-anchor` is the browser's automatic "keep the content you are
416+
* looking at in place" behaviour: when content is inserted ABOVE the viewport
417+
* it adjusts `scrollTop` by the inserted height. That is the wrong default for
418+
* this container, because its scroll offset is not the browser's to manage —
419+
* three things own it, and only three:
420+
*
421+
* 1. the tail follow in `chat-virtual-list.tsx`, which pins the offset to the
422+
* growing end of a streaming turn,
423+
* 2. the reader's own scrolling,
424+
* 3. the persisted-position restore in `chat.tsx`.
425+
*
426+
* With anchoring left on, (1) cannot tell a reader scrolling away from the
427+
* browser nudging the offset underneath it: the off-switch reads "the
428+
* container moved off my pin", abandons the follow, and the answer is stranded
429+
* below the fold again — the original defect, half-fixed. Measured on the dev
430+
* instance: the offset jumped 55 → 125 px on its own while the transcript grew
431+
* above the viewport, with no input event at all. Pinning is strictly more
432+
* correct than anchoring for a transcript that grows at the tail, so anchoring
433+
* is off: the reader who scrolled up keeps the exact offset they chose, and the
434+
* reader watching the tail stays on it.
435+
*
436+
* Same placement rule as `message-animate-in` below: outside any `@layer`, so
437+
* Tailwind's content scan cannot purge it, and not spelled as a utility so the
438+
* rule survives a Tailwind upgrade that renames one. Pinned by
439+
* `test/chat-scroll-anchoring.test.ts`. */
440+
.chat-scroll {
441+
overflow-anchor: none;
442+
}
443+
413444
/* Message entry animation (webui-parity 61, G1).
414445
*
415446
* The class has been carried on the five message containers since ticket

0 commit comments

Comments
 (0)