Skip to content

[fix] the live transcript follows the tail only when you are already at the bottom - #336

Merged
Lanznx merged 2 commits into
mainfrom
fix/transcript-stick-to-bottom
Sep 5, 2026
Merged

Lanznx merged 2 commits into
mainfrom
fix/transcript-stick-to-bottom

Conversation

@yui0303

@yui0303 yui0303 commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Root cause

TranscriptPanel scrolled itself to the bottom on every render pass of the transcript:

useEffect(() => {
  bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [runs]);

runs is 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. CoachFeed had the identical unconditional pattern and the identical problem.

New behaviour

Standard follow-the-tail, like a log viewer or a chat:

  • At (or within 48px of) the bottom → new content keeps pulling the view down.
  • Scrolled up → the scroll position is left completely alone; new text appends below.
  • Scrolling back to the bottom re-arms the follow.
  • While detached, the transcript shows a small jump to latest pill (bottom-centre) that scrolls to the tail and re-arms. The coach feed gets the fix but no pill — its ask bar already sits under the feed.

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 — scrollend alone 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 scrollTop decreases 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.

ScrollArea gains an optional viewportRef prop forwarded to ScrollAreaPrimitive.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 in src/lib/useStickToBottom.test.ts: at the bottom, within the threshold, above it, non-overflowing content, and sub-pixel overshoot — alongside isUserTakeover(metrics, tolerance), which decides whether a mid-flight scroll event came from the user.

transcript.jumpToLatest added to both the zh-TW and en dictionaries.

Verification

  • bunx tsc --noEmit — clean.
  • bunx vitest run — 30 files, 335 tests passing.

Closes #331

🤖 Generated with Claude Code

…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>
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

✅ SonarQube Quality Gate passed — pathorsAI_parley

0 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>
@Lanznx
Lanznx merged commit 1a691b8 into main Sep 5, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[fix] the live transcript yanks itself to the bottom while you are reading further up

3 participants