Skip to content

[Detail Bug] Chat UI: Jump-to-bottom during streaming keeps forcing scroll after user drags scrollbar #152

Description

@detail-app

Detail Bug Report

https://app.detail.dev/org_befd6425-a158-4e24-9d4d-1e5c08769515/bugs/bug_b53258b4-8872-4122-93f3-3861ce6747a6

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

Summary

  • Context: The createScrollAnchor composable implements an inverted scroll model for streaming chat interfaces, where user scroll position must be respected to show/hide a "new content" indicator.
  • Bug: During active stream following (after user clicks "jump to bottom" indicator), the programmaticScrollActive flag stays true indefinitely. If the user then drags the native scrollbar away, the system ignores the scroll-away and continues calling scrollTo() on each streaming chunk, forcibly overriding the user's scroll position.
  • Actual vs. expected: After clicking “jump to bottom” during streaming, a native scrollbar drag away should stop following newest content and show the new-content indicator; actually, subsequent streaming chunks keep auto-scrolling to the bottom and the indicator state does not update.
  • Impact: The system "fights" the user's scroll position during streaming, forcibly scrolling back to the bottom on each chunk.

Code with Bug

async function followActiveStream(): Promise<void> {
  do {
    // ...
    programmaticScrollActive = true;  // <-- BUG 🔴 never reset, blocks user scroll-away detection
    followedContainer.scrollTo({
      top: currentScrollHeight,
      behavior: "auto",
    });
    // Flag never reset — stays true across iterations
  } while (activeStreamFollowDirty);
}
onUserScroll(): void {
  if (!container) return;
  if (isNearBottom()) {
    // ...reset state...
  } else if (userScrollIntent) {
    stopFollowingNewestContent();  // Works if intent event fired
  } else if (!userScrollIntent && !programmaticScrollActive) {
    // BUG: programmaticScrollActive is always true during streaming
    // So this branch never reached for scrollbar-only scroll-aways
    stopFollowingNewestContent();
  }
}
async jumpToBottom(): Promise<void> {
  followsNewestContent = true;
  followsActiveStreamAfterJump = true;  // <-- BUG 🔴 enables followActiveStream() loop
  // ...
}

Explanation

  • followActiveStream() sets programmaticScrollActive = true before each scrollTo({ behavior: "auto" }) but never sets it back to false.
  • Native scrollbar drags typically fire only scroll events (no wheel/touchstart/pointerdown/keydown), so userScrollIntent remains false.
  • With programmaticScrollActive stuck true, onUserScroll() will not call stopFollowingNewestContent() for scrollbar-only scroll-aways; on the next streaming chunk, followActiveStream() continues and calls scrollTo() again, overriding the user’s position.

Codebase Inconsistency

async scrollOnce(): Promise<void> {
  followsNewestContent = true;
  followsActiveStreamAfterJump = false;  // Prevents followActiveStream()
  clearIndicatorStateInternal();
  await performScroll();
}

scrollOnce() explicitly disables followsActiveStreamAfterJump, so normal message sending does not enter the streaming-follow loop, masking the issue.

Failing Test

it("stops overriding scroll position when user drags scrollbar during active stream follow (no intent events)", async () => {
  vi.useFakeTimers();
  const scrollContainer = document.createElement("div");
  setScrollGeometry(scrollContainer, 800, 3_657, 200);
  const scrollToSpy = vi.spyOn(scrollContainer, "scrollTo");
  const scrollAnchor = createScrollAnchor({ indicatorDelayMs: 150 });
  scrollAnchor.attach(scrollContainer);
  await scrollAnchor.jumpToBottom();  // User clicks indicator
  scrollToSpy.mockClear();

  // First streaming chunk — system correctly follows
  setScrollGeometry(scrollContainer, 3_457, 4_157, 200);
  await scrollAnchor.onContentAdded();
  expect(scrollToSpy).toHaveBeenLastCalledWith({ top: 4_157, behavior: "auto" });
  scrollToSpy.mockClear();

  // User drags scrollbar away (no wheel/touch/pointer/keydown events)
  setScrollGeometry(scrollContainer, 2_000, 5_157, 200);
  scrollAnchor.onUserScroll();

  // Next streaming chunk — system should NOT override user's position
  setScrollGeometry(scrollContainer, 2_000, 6_157, 200);
  await scrollAnchor.onContentAdded();
  vi.advanceTimersByTime(150);

  expect(scrollToSpy).not.toHaveBeenCalled();  // FAILS
  expect(scrollAnchor.unseenCount).toBe(1);    // FAILS
  expect(scrollAnchor.showIndicator).toBe(true);  // FAILS
  scrollAnchor.cleanup();
});

Test output:

expected "scrollTo" not to be called at all, but actually been called 1 times
Received: [{ behavior: "auto", top: 6157 }]

Recommended Fix

Reset programmaticScrollActive after each scrollTo() in followActiveStream():

followedContainer.scrollTo({ top: currentScrollHeight, behavior: "auto" });
programmaticScrollActive = false;  // Reset — auto-behavior scroll is synchronous

History

This bug was introduced in commit 30ac922. The commit added the followActiveStream() function to keep the view pinned to the bottom after an explicit jump-to-bottom during streaming. The developer added programmaticScrollActive = true before each scrollTo() call to suppress scroll event handling, but forgot to reset the flag to false after the scroll completes. The flag stays true indefinitely, blocking onUserScroll() from detecting user scroll intent via native scrollbar interactions.

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