fix(web): preserve chat scroll ownership

This commit is contained in:
kingwl
2026-08-02 16:51:10 +08:00
committed by imccyu
parent bf6cb38fbc
commit 8de33e42f7
6 changed files with 759 additions and 71 deletions
@@ -14,7 +14,7 @@ While a session exists, `ConversationRoot` always supplies a `wrapActiveBody` ow
Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, chains `wheel` with `{ passive: false }`: while the capped textarea can still scroll in that direction it keeps the native gesture; only at its own edge does it `preventDefault` and apply `deltaY` to the host.
Chat history prepend follows reader intent through stable rendered node/call identities rather than whole-scrollport height deltas. `ChatView` records the first visible `data-chat-anchor-key` and its top relative to the scrollport when paging starts, reselects the currently visible stable anchor after every reader scroll while the request is in flight, and compensates by that row's post-prepend rectangle delta. Reaching the bottom or appending the reader's own message cancels the paging anchor, so a late page cannot pull the view away from the newest content. ChatView's single `ResizeObserver` owns bottom-follow decisions for column and sticky-composer height changes: it follows streaming, tool disclosure, and draft resize only while bottom ownership remains pinned, without a second per-chunk scroll write.
Chat history prepend follows reader intent through stable rendered node/call identities rather than whole-scrollport height deltas. `ChatView` records the first visible `data-chat-anchor-key` and its top relative to the scrollport when paging starts, reselects the currently visible stable anchor after every reader scroll while the request is in flight, and compensates by that row's post-prepend rectangle delta. Reaching the bottom or appending the reader's own message cancels the paging anchor, so a late page cannot pull the view away from the newest content. Bottom follow is reader-owned intent, distinct from instantaneous geometry: raw scroll events never change ownership because programmatic writes, browser clamping or anchoring, and reader movement share the same event shape. `ChatView` arms a reader gesture from wheel/trackpad input that reaches the host, direct pointer or touch scrolling, native-scrollport pointer input, and vertical or focus-navigation keys; a nested overflow owner that can consume the direction does not arm the transcript. Wheel handling remains passive and takes its pre-input baseline from the last main-thread-delivered or programmatically written `scrollTop`, because Chromium may advance compositor geometry before delivering a passive wheel event. Only host movement during that gesture may transition between following and reading, and two idle animation frames end it; a new tail floor excludes its forced clamp from reader movement, and reading mode can reclaim follow only on reader movement toward the floor. A non-reader scroll drift re-pins while following and only refreshes the semantic saved position while reading. Semantic history restore and prepend correction cancel any active reader gesture before applying and saving their explicit ownership state. ChatView's single `ResizeObserver` owns bottom-follow decisions for column and sticky-composer height changes: it follows streaming, tool disclosure, and draft resize only while bottom ownership remains pinned and no reader gesture is active, without a second per-chunk scroll write.
## Alternatives considered
@@ -26,6 +26,8 @@ Chat history prepend follows reader intent through stable rendered node/call ide
**Keep StatsLine inside ChatView below the message column.** Rejected: outside the sticky composer it would scroll away while the input stayed pinned.
**Ignore or target-match the next scroll event after a programmatic bottom write.** Rejected: no-op writes may emit no event, repeated writes may coalesce, and stream finalization can shrink-clamp then regrow the scrollport before one delayed event reports a position different from the last target. That event is indistinguishable from reader movement without input provenance.
## Consequences
Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow cannot overwrite a reader's newer scroll decision. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft.
Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve the reader's newer scroll decision; outside an active reader gesture, delayed programmatic scroll events cannot change follow ownership. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft.