[fix] the live transcript follows the tail only when you are already at the bottom - #336
Merged
Merged
Conversation
…at the bottom
The transcript panel scrolled itself to the bottom on every update:
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [runs]);
`runs` is rebuilt from every transcript update including interim, non-final
segments, so during a meeting this fired every few seconds and always beat the
user. Scrolling up to re-read something said a minute ago was impossible — the
view was yanked away mid-sentence. CoachFeed had the identical pattern.
Both now share one hook, useStickToBottom: it chases the tail only while the
viewport is within 48px of the bottom, leaves the scroll position untouched
once the user has scrolled up, and re-arms when they come back down. A scroll
the hook itself started is guarded by a ref flag so it is not mistaken for the
user leaving the bottom; the guard is released as soon as the bottom is
reached, on any wheel/touch/key (the user has taken over), or after a 700ms
backstop — scrollend is not dependable in WKWebView.
The jump-to-timestamp effect runs through the same guard, so it is neither
misread as the user drifting off the tail nor overridden by the next segment.
The Radix ScrollArea gains an optional viewportRef prop, since the viewport is
the element that actually scrolls. While detached, the transcript shows a
"jump to latest" pill so the way back is one click rather than a long drag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
✅ SonarQube Quality Gate passed — pathorsAI_parley0 open issues on this PR. |
…ipe does
The in-flight guard only recognised wheel, touchmove and keydown as "the user
took over". Dragging the Radix scrollbar thumb fires none of those, and the
thumb is a sibling of the viewport rather than a descendant, so no listener on
the viewport can see it either. During the 700ms guard window that opens after
every content update — which, on a transcript updating every few seconds, is a
meaningful fraction of the time — a drag upward had its scroll events swallowed
and was snapped back to the tail: exactly the complaint this branch is fixing.
Our own tail-chasing scrolls only ever travel toward the bottom, so scrollTop
decreasing is unambiguously someone else's hand, guard or no guard. The scroll
handler now tracks the previous scrollTop and releases the guard when a
mid-flight event shows the viewport moved up by more than 2px of jitter.
The rule is limited to tail-chasing scrolls: jump-to-timestamp legitimately
travels upward, so the guard now records which kind of scroll it is covering
("tail" vs "free") and a jump no longer risks cancelling itself. The existing
early release when the bottom is reached is kept ahead of the new check, which
also covers scrollTop being clamped downward when content shrinks — an interim
segment replaced by a shorter final one moves nothing the user cares about.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause
TranscriptPanelscrolled itself to the bottom on every render pass of the transcript:runsis rebuilt from every transcript update — including interim, non-final segments — so during a meeting this fires every few seconds and always beats the user. Scrolling up to re-read something said a minute ago was impossible: the view was yanked away mid-sentence.CoachFeedhad the identical unconditional pattern and the identical problem.New behaviour
Standard follow-the-tail, like a log viewer or a chat:
How
One shared hook,
src/lib/useStickToBottom.ts, used by both panes.A scroll the hook itself starts is guarded by a ref flag, so a programmatic scroll is never mistaken for the user leaving the bottom and a smooth scroll still in flight cannot spuriously disarm the follow. The guard is released as soon as the bottom is reached, on any
wheel/touchmove/keydown(the user has taken over the viewport), or after a 700ms backstop —scrollendalone is not dependable in WKWebView.Dragging the Radix scrollbar thumb fires none of those events, and the thumb is a sibling of the viewport, so it can't be listened for there either. Since a tail-chasing scroll only ever travels toward the bottom, the handler also releases the guard when
scrollTopdecreases by more than 2px of jitter — that can only be a hand on the scrollbar. The rule is scoped to tail-chasing scrolls, because jump-to-timestamp legitimately travels upward and must not cancel itself.The jump-to-timestamp effect runs through that same guard: it is neither misread as the user drifting off the tail while it travels, nor overridden by the next arriving segment, because the follow is re-derived from where the jump actually landed.
ScrollAreagains an optionalviewportRefprop forwarded toScrollAreaPrimitive.Viewport— the viewport is the element that actually scrolls, and it wasn't reachable before. The shadcn component is otherwise untouched, so it stays updatable.The pure part,
isAtBottom(metrics, threshold), is unit-tested insrc/lib/useStickToBottom.test.ts: at the bottom, within the threshold, above it, non-overflowing content, and sub-pixel overshoot — alongsideisUserTakeover(metrics, tolerance), which decides whether a mid-flight scroll event came from the user.transcript.jumpToLatestadded to both thezh-TWandendictionaries.Verification
bunx tsc --noEmit— clean.bunx vitest run— 30 files, 335 tests passing.Closes #331
🤖 Generated with Claude Code