Skip to content

[Detail Bug] Chat: Keyboard Tab focus can cause end-of-stream scroll yank to bottom on short/new conversations #225

Description

@detail-app

Detail Bug Report

https://app.detail.dev/org_befd6425-a158-4e24-9d4d-1e5c08769515/bugs/bug_aa9324e5-d71c-4dd0-bafe-0792cc804c22

Introduced in #141 by @WilliamAGH on Jul 28, 2026

Summary

  • Context: createScrollAnchor (frontend/src/lib/composables/createScrollAnchor.svelte.ts) is the Svelte 5 composable that drives chat scroll behavior. Its header comment promises to eliminate "scroll fighting" — its design intent is that an explicit jump enables follow and any genuine scroll-away disables it immediately.
  • Bug: The bug is a narrower, unhandled edge of that intentional design: when the programmatic scroll is a no-op because the container is already at the target and the content fits the viewport (scrollHeight ≤ clientHeight, maxScroll = 0), the scrollTo({ top: scrollHeight }) clamps to the current position and fires no scroll/scrollend event.
  • Actual vs. expected: The viewport was yanked from the user's scrollTop=1949 to the bottom, and the indicator did not rise. The viewport stayed at the user's position and the unseen indicator rose.
  • Impact: The user-visible symptom is a single yank to the bottom at stream end — immediately recoverable by scrolling back; no content is lost and no state is permanently corrupted.

Code with Bug

frontend/src/lib/composables/createScrollAnchor.svelte.ts:

  async function performScroll(): Promise<boolean> {
    const scrollContainer = container;
    const scrollVersion = scrollStateVersion;
    if (!scrollContainer || userScrollIntent) {
      return false;
    }
    programmaticScrollActive = true;
    await tick();

    const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    let performedScroll = false;
    let previousScrollHeight = -1;
    for (let pass = 0; pass <= MAX_FINAL_REVEAL_RECONCILIATION_PASSES; pass++) {
      await tick();
      const currentScrollHeight = scrollContainer.scrollHeight;
      if (currentScrollHeight === previousScrollHeight) {
        break;
      }
      previousScrollHeight = currentScrollHeight;
      performedScroll = true;
      programmaticScrollActive = true;
      scrollContainer.scrollTo({
        top: currentScrollHeight,         // <-- BUG 🔴 may be a no-op when content fits; no events fire, flag never clears
        behavior: prefersReducedMotion ? "auto" : "smooth",
      });
    }
    return performedScroll;
  }
    onUserScroll(): void {
      if (!container) return;
      if (isNearBottom()) {
        followsNewestContent = true; userScrollIntent = false;
        userScrollIntentStartOffset = null; programmaticScrollActive = false;
        unseenCount = 0; showIndicator = false;
      } else if (userScrollIntent) {
        stopFollowingNewestContent();
      } else if (!userScrollIntent && !programmaticScrollActive) {   // <-- BUG 🔴 branch-3 scroll-away is blocked while flag is stuck
        stopFollowingNewestContent();
      }
    }

Explanation

  • performScroll() intentionally leaves programmaticScrollActive = true after returning to avoid misattributing in-flight smooth scroll events to the user (covered by the existing test "keeps following during an intermediate smooth programmatic scroll event").
  • When the conversation is short enough that scrollHeight ≤ clientHeight (maxScroll = 0), scrollTo({ top: scrollHeight }) clamps to the current position and fires 0 scroll/scrollend events. With no events, onUserScroll() never enters the near-bottom branch that clears programmaticScrollActive, so the flag stays stuck true.
  • Later in the same stream, the user can generate a scroll event with no intent event via keyboard Tab navigation: Tab is excluded from USER_SCROLL_KEYS, so userScrollIntent stays false, but focusing an off-screen link triggers the browser’s default focus-scrollIntoView which emits a scroll event.
  • That scroll goes through onUserScroll() branch 3 (“no intent”) but branch 3 is gated by !programmaticScrollActive; with the stuck flag, stopFollowingNewestContent() never runs, so followsNewestContent remains true.
  • At stream end, revealFinalContentIfFollowing() sees followsNewestContent === true and performs another programmatic scroll, yanking the viewport back to the bottom and suppressing the unseen-content indicator.

Codebase Inconsistency

followActiveStream uses the safer synchronous reset pattern around an instant scroll:

      programmaticScrollActive = true;
      followedContainer.scrollTo({ top: currentScrollHeight, behavior: "instant" });
      programmaticScrollActive = false;      // synchronous reset used elsewhere

Recommended Fix

Detect when performScroll() is a complete no-op (target clamps to current position) and clear programmaticScrollActive only in that case; keep the existing behavior for real smooth-scroll moves so in-flight events remain shielded.

    let performedScroll = false;
    let movedDuringScroll = false;
    let previousScrollHeight = -1;
    for (let pass = 0; pass <= MAX_FINAL_REVEAL_RECONCILIATION_PASSES; pass++) {
      await tick();
      const currentScrollHeight = scrollContainer.scrollHeight;
      if (currentScrollHeight === previousScrollHeight) break;
      previousScrollHeight = currentScrollHeight;
      performedScroll = true;

      if (scrollContainer.scrollTop + scrollContainer.clientHeight < currentScrollHeight) {
        movedDuringScroll = true;
      }

      programmaticScrollActive = true;
      scrollContainer.scrollTo({
        top: currentScrollHeight,
        behavior: prefersReducedMotion ? "auto" : "smooth",
      });
    }

    if (!movedDuringScroll) {
      programmaticScrollActive = false;
    }

History

This bug was introduced in commit 30ac922. That commit ("feat(frontend): follow the active stream after an explicit jump-to-bottom") added a "jump-follows-stream" mode that keeps the view pinned to the bottom after a user clicks the new-content indicator, and to shield the in-flight smooth scroll events that mode generates it introduced the programmaticScrollActive flag. The bug slipped in because the flag is set unconditionally at the top of performScroll (:268) before any actual scroll happens and is only ever cleared implicitly via scroll/scrollend events; when the content already fits the viewport (maxScroll === 0) the scrollTo is a no-op, fires no events, and the flag is left stuck. The same commit made this unrecoverable by setting followsActiveStreamAfterJump = false in scrollOnce (:574), which prevents the followActiveStream path (the only synchronous re-clear) from running for the rest of the stream — so the stuck flag survives until a later no-intent scroll (e.g. Tab-focus into an off-screen link) hits onUserScroll's branch-3 gate and triggers the yank. Later commits only narrowed the bug: 79f3a1f7 added a synchronous programmaticScrollActive = false reset inside followActiveStream (self-healing the jumpToBottom/pinned-bottom path, which is why probe 1 found that case fires events) without touching performScroll, and f143a029 only changed indicator-counting gating and did not touch the flag, performScroll, or scrollOnce.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions