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.
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
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.scrollHeight ≤ clientHeight,maxScroll = 0), thescrollTo({ top: scrollHeight })clamps to the current position and fires noscroll/scrollendevent.scrollTop=1949to the bottom, and the indicator did not rise. The viewport stayed at the user's position and the unseen indicator rose.Code with Bug
frontend/src/lib/composables/createScrollAnchor.svelte.ts:Explanation
performScroll()intentionally leavesprogrammaticScrollActive = trueafter 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").scrollHeight ≤ clientHeight(maxScroll = 0),scrollTo({ top: scrollHeight })clamps to the current position and fires 0scroll/scrollendevents. With no events,onUserScroll()never enters the near-bottom branch that clearsprogrammaticScrollActive, so the flag stays stucktrue.scrollevent with no intent event via keyboardTabnavigation:Tabis excluded fromUSER_SCROLL_KEYS, souserScrollIntentstaysfalse, but focusing an off-screen link triggers the browser’s default focus-scrollIntoViewwhich emits ascrollevent.scrollgoes throughonUserScroll()branch 3 (“no intent”) but branch 3 is gated by!programmaticScrollActive; with the stuck flag,stopFollowingNewestContent()never runs, sofollowsNewestContentremainstrue.revealFinalContentIfFollowing()seesfollowsNewestContent === trueand performs another programmatic scroll, yanking the viewport back to the bottom and suppressing the unseen-content indicator.Codebase Inconsistency
followActiveStreamuses the safer synchronous reset pattern around an instant scroll:Recommended Fix
Detect when
performScroll()is a complete no-op (target clamps to current position) and clearprogrammaticScrollActiveonly in that case; keep the existing behavior for real smooth-scroll moves so in-flight events remain shielded.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
scrollevents that mode generates it introduced theprogrammaticScrollActiveflag. The bug slipped in because the flag is set unconditionally at the top ofperformScroll(:268) before any actual scroll happens and is only ever cleared implicitly viascroll/scrollendevents; when the content already fits the viewport (maxScroll === 0) thescrollTois a no-op, fires no events, and the flag is left stuck. The same commit made this unrecoverable by settingfollowsActiveStreamAfterJump = falseinscrollOnce(:574), which prevents thefollowActiveStreampath (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) hitsonUserScroll's branch-3 gate and triggers the yank. Later commits only narrowed the bug:79f3a1f7added a synchronousprogrammaticScrollActive = falsereset insidefollowActiveStream(self-healing thejumpToBottom/pinned-bottom path, which is why probe 1 found that case fires events) without touchingperformScroll, andf143a029only changed indicator-counting gating and did not touch the flag,performScroll, orscrollOnce.