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.
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
createScrollAnchorcomposable implements an inverted scroll model for streaming chat interfaces, where user scroll position must be respected to show/hide a "new content" indicator.programmaticScrollActiveflag staystrueindefinitely. If the user then drags the native scrollbar away, the system ignores the scroll-away and continues callingscrollTo()on each streaming chunk, forcibly overriding the user's scroll position.Code with Bug
Explanation
followActiveStream()setsprogrammaticScrollActive = truebefore eachscrollTo({ behavior: "auto" })but never sets it back tofalse.scrollevents (nowheel/touchstart/pointerdown/keydown), souserScrollIntentremainsfalse.programmaticScrollActivestucktrue,onUserScroll()will not callstopFollowingNewestContent()for scrollbar-only scroll-aways; on the next streaming chunk,followActiveStream()continues and callsscrollTo()again, overriding the user’s position.Codebase Inconsistency
scrollOnce()explicitly disablesfollowsActiveStreamAfterJump, so normal message sending does not enter the streaming-follow loop, masking the issue.Failing Test
Test output:
Recommended Fix
Reset
programmaticScrollActiveafter eachscrollTo()infollowActiveStream():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 addedprogrammaticScrollActive = truebefore eachscrollTo()call to suppress scroll event handling, but forgot to reset the flag tofalseafter the scroll completes. The flag staystrueindefinitely, blockingonUserScroll()from detecting user scroll intent via native scrollbar interactions.