fix(chat): attach scroll anchor when the scroller appears; only reader scroll releases a pin (BUG-1043, BUG-1044)

- The anchor listener and follow observer now attach whenever the scroller
  element itself appears (checked after every commit, no-op unless element,
  active or resetKey changed). The home page mounts `.conversation` after its
  loading screen with unchanged active/resetKey, so a directly opened session
  never got a listener, never landed on its newest content, showed the jump
  chip under short replies and did not follow after pressing it.
- After a pin, geometry no longer releases the hold: only a wheel, touch drag,
  scroll key or scrollbar press followed by a scroll within 1s does. The
  rectification pin rests 94px from the bottom, inside the 96px threshold,
  which dragged long replies to their last line.
- Real React lifecycle tests (loading screen -> reveal, 94px rest), DESIGN,
  BUG history, PROGRESS, CHANGELOG, device checklist and CDP screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-26 10:42:12 +08:00
co-authored by Claude Opus 5.5
parent e45d97019f
commit da2613ffd9
12 changed files with 792 additions and 108 deletions
+2 -1
View File
@@ -258,7 +258,8 @@ Both chat surfaces pin a new turn at its head instead of following the last stre
- **Head:** the user row of this turn. When this turn has no user row (rectification choice tap, opening, auto-continue) the head is the new assistant row.
- **Pin:** on send (and on those rectification new-turn entries) the scroller places that row at the top with a `space-4` gap and then holds still while the reply grows downward.
- **Spacer:** the last turn's entry — the `.message-list` child that holds the assistant row, its `.message-actions`, follow-up chips and (rectification) delivery card — gets `min-height: calc(var(--conversation-viewport) - var(--latest-turn-head-height))` plus `align-content: start`, both variables written onto the scroller by `useConversationScrollAnchor`. The spacer trails the whole turn: the actions row sits right under the text at its own `margin: -4px 0 8px`, and the empty space comes after it (BUG-1042; the assistant row itself never carries the spacer — that pushed the actions half a screen down). `align-content: start` keeps the rectification wrap's grid rows from stretching into the space. Not sticky, and `.conversation`'s `padding-bottom` is unchanged. The spacer lasts until the next send; switching sessions clears it.
- **History:** opening another session still lands on the newest content once.
- **Release:** the pin holds until the reader scrolls — a wheel, a touch drag, a scroll key outside a text field, or a press on the scroller's own scrollbar, followed by a scroll within 1s. Scrolling back to within 96px of the bottom then resumes following; scrolling up leaves the viewport where they put it. Where the pin happens to rest never releases it: the rectification surface rests 94px from the bottom (its spacer plus the jump-chip clearance), inside the threshold, and treating that as "back at the bottom" dragged long rectification replies to their last line (BUG-1044). Taps on buttons inside a message do not count.
- **History:** opening another session still lands on the newest content once. That includes the first session on page load (refresh, `?c=` deep link, boot landing): the scroller only mounts after the loading screen, and the hook attaches its scroll listener and resize observer whenever the scroller element appears, not only when `active` or the session key changes (BUG-1043). Without that, a directly opened session showed “跳到最新” under a short reply and never followed.
- **Jump to latest:** if the growing turn (or the reader scrolling) leaves more than 96px below the fold, the shared chip appears; pressing it sticks to the bottom for the rest of the turn.
### Jump to latest