Skip to content

fix(web): show scroll-to-end as soon as the last message slips under …

MacroscopeApp / Macroscope - UI Consistency failed Sep 2, 2026 in 10m 29s

UI Consistency: 1 issue found

  • apps/web/src/components/chat/MessagesTimeline.tsx — the new end-distance formula is composer-inset dependent, but contentInsetEndAdjustment was removed from handleScroll's deps, dropping the only trigger that recomputed the end state on composer resize. On web a composer shrink (or any inset change while scrolled away from the end) emits no scroll event, so the scroll-to-end pill and live-follow state stay stale until the next manual scroll.

The core geometry change itself checks out: getState().contentLength includes the composer inset spacer (LegendList's web scroll view renders it as real content padding and getContentSize adds getContentInsetEnd), the composer is an absolute bottom-0 overlay so scrollLength spans the covered band, and the inset therefore cancels. Removing the state.isAtEnd short-circuit is also consistent — LegendList's own isAtEnd subtracts the inset again and is true anywhere in the composer-height band, and the underflow case (contentSize < scrollLength) still resolves true through the plain geometry.

Details

Note

Your check run agent prompt is: .macroscope/check-run-agents/ui-consistency.md
More information about how Check Run Agents work can be found in our Help Center.

Files examined

  • apps/web/src/components/chat/MessagesTimeline.logic.ts (changed)
  • apps/web/src/components/chat/MessagesTimeline.tsx (changed)
  • apps/web/src/components/chat/MessagesTimeline.test.tsx (changed)
  • apps/web/src/components/ChatView.tsx (changed; composer overlay layout, onIsAtEndChange, follow opt-out listeners)
  • apps/web/src/components/chat/timelineScrollAnchoring.ts (context for viewport/inset conventions)
  • patches/@[email protected] (inset and isAtEnd semantics)

Verification

  • Confirmed all three call sites of resolveTimelineIsAtEnd were updated to the single-argument signature; no remaining two-argument callers, so the type contract is consistent.
  • Confirmed contentInsetEndAdjustment is rendered as real content padding on web and is included in getState().contentLength, so the inset-cancellation argument in the new comment is accurate.
  • Confirmed the composer is a pointer-events-none absolute inset-x-0 bottom-0 overlay over the messages wrapper, so the list viewport (and scrollLength) includes the band hidden by the composer.
  • Checked underflow, short-thread, anchored-end-space, and footer-height cases for behavior changes from removing the isAtEnd short-circuit; each still resolves consistently, and onIsAtEndChange early-returns while live-follow is armed, so no new pill flicker during send anchoring.
  • Traced LegendList's updateContentInsetEndAdjustment: it forces an internal checkAtBottom but never calls the consumer onScroll; a synthetic scrollBy only happens for a growing inset while within the maintain-scroll-at-end threshold (web skips shrink). This is the basis of the reported staleness finding.
  • Test coverage matches the new contract (band, half-viewport, flag-does-not-short-circuit, geometry-missing fallback); no additional focused test is required beyond the reported recompute trigger.

🔍 Agent activity — 22 tool calls

💭 I need to check whether removing the endInset parameter and the early return on state.isAtEnd from resolveTimelineIsAtEnd still preserves correct behavior, so I'll look at the relevant files.

1. browse_code — apps/web/src/components/chat/MessagesTimeline.logic.ts

2. git_tools — 94b9b04

💭 I'm checking how contentInsetEndAdjustment interacts with contentLength in MessagesTimeline.tsx—since the inset is applied as list padding, contentLength should already include it, so the distance to the visible content bottom (accounting for the composer overlay) works out to contentLength minus scroll minus scrollLength. I also want to confirm removing the isAtEnd short-circuit is safe: at the hard bottom, that geometric distance evaluates to zero, well within the threshold, so the fallback still resolves correctly unless the geometry itself is unavailable.

3. git_tools — contentInsetEndAdjustment|contentInset|composerOverlayHeight

💭 I'm noticing contentInsetEndAdjustment gets passed to a child component, possibly the inner list, so I want to check how MessagesTimeline.tsx handles that around lines 500-760.

4. browse_code — apps/web/src/components/chat/MessagesTimeline.tsx

💭 I need to figure out whether contentLength in LegendList already accounts for contentInsetEndAdjustment, so I should look through the vendored fork in the repo to check its semantics.

5. git_tools — contentInsetEndAdjustment

💭 I'm checking the patch to see how contentInsetEndAdjustment influences the isAtEnd and extraContentPadding calculations.

6. git_tools — extraContentPadding|isAtEnd|contentLength

💭 I'm digging into how LegendList computes isAtEnd, checking whether contentLength already accounts for the composer inset spacer, and questioning whether removing that short-circuit check is actually safe for the case where content is shorter than the scroll viewport. Working through the numbers, this actually stays consistent since the user really can scroll 100px further, so the pill correctly appears. Now I want to verify whether the composer truly overlays the list as an absolute-positioned element rather than sitting in normal flow, so I need to check the ChatView layout code around the bottom inset logic, and also confirm whether the content inset adjustment affects the measured content size or is applied separately as a native scroll-view inset.

7. git_tools — function getContentInsetEnd|function getContentSize

💭 That change isn't in the patch, so it must be unchanged upstream—I don't have node_modules available to verify directly. Let me search the repo for "getContentInsetEnd" instead, and also revisit how ChatView uses resolveTimelineIsAtEnd along with the pill logic elsewhere in the file.

8. browse_code — apps/web/src/components/ChatView.tsx

💭 I'm noting that with the new behavior, being anywhere within the composer band now makes viewportIsAwayFromEnd return false, more sensitive than before when the endInset subtraction and isAtEnd short-circuit made it less reactive — that's the intended fix. But I'm worried this could cause handleTouchMove and handlePointerDown to break live-follow more readily, and want to check whether the geometry flickers false during streaming growth. The comment at line 4217 explains the follow re-arm band uses a 40px threshold, and streaming growth can make isAtEnd flicker false for a frame before follow-scroll catches up. My real concern is whether contentLength accounts for the composer inset spacer — if it doesn't, removing the endInset subtraction could shift the "at end" threshold by the composer's height, meaning at the true bottom the calculation would always exceed 40px and return false, permanently breaking live-follow. I want to check how LegendList's own isAtEnd handles this subtraction to see if the same issue applies there. I'm working through the math for both cases: whether getContentSize includes the inset padding or not, and in both scenarios the calculation seems to yield true near the bottom, consistent with the PR's claim about the band. I still need to verify this by finding the actual getContentSize implementation, so I'm looking for the patch file or source to check for contentInsetEnd context lines.

9. browse_code — apps/web/src/components/chat/timelineScrollAnchoring.ts

💭 I'm working through the coordinate math here—checking whether the usable viewport calculation (scroll length minus composer overlay minus anchor offset) stays consistent with how content length and inset relate at the scroll boundary, so the real bottom of content lines up correctly when computing distances. The math checks out only if contentLength actually includes the inset spacer, so I need to verify this by searching the patch specifically for where contentLength is defined in the imperative handle, since my earlier grep results seem to have merged unrelated matches together.

10. git_tools — contentLength

💭 Since contentLength isn't in the patch, it must come from an upstream getState call, so I want to check other usages of state.contentLength across the repo for conventions. I also want to look at the git history around the endInset logic in MessagesTimeline.logic.ts to see if an earlier commit already established the expected behavior this test is now changing.

11. git_tools — apps/web/src/components/chat/MessagesTimeline.logic.ts

💭 I should track down which commit introduced the endInset parameter.

12. git_tools

13. git_tools — 1c7d059

💭 I'm digging into whether contentLength accounts for the composer inset spacer, since the old and new logic diverge on whether scrollLength also includes the composer-covered band. In the old formula, the distance calculation treats the full viewport as extending under the composer, meaning the pill only shows once the real content bottom is within 40px of that full viewport edge. That's likely the reported bug: users have to scroll past a whole composer height before the pill appears. The new formula instead measures distance to the visible edge just above the composer, which depends on whether the composer overlays the scroll viewport or sits beneath it — I need to check the ChatView layout around the timeline container and composer, and confirm whether contentInsetEndAdjustment actually pads the content on web.

14. browse_code — apps/web/src/components/ChatView.tsx

💭 I'm confirming the composer overlays the messages wrapper as an absolute bottom band, so the timeline's scrollLength naturally includes that band and the inset-cancellation geometry holds—provided contentLength also accounts for the inset padding. Now I'm checking whether contentInsetEndAdjustment actually adds real padding on web versus just being used for the mobile KeyboardAware wrapper. Since I can't inspect node_modules directly, I want to search the repo for any documentation or notes explaining contentInsetEnd's semantics, plus look for existing tests covering this scroll behavior.

15. browse_code — In this repo, the web chat timeline uses LegendList (@legendapp/list 3.3.5, patched in patches/@[email protected]) with the contentInsetEndAdjustment prop. On web, how is contentInsetEndAdjustment implemented — does it add padding to the scrollable content (so that the scroll element's scrollHeight and the list state's contentLength include it), or is it a non-content inset? Also, does getState().contentLength include the end inset adjustment? Cite the relevant code from the patch file or any vendored legend-list source / docs / notes in the repo.

💭 I'm checking edge cases where removing the isAtEnd short-circuit might cause regressions, like when content is shorter than the viewport but a composer inset gets added — in that case the geometry check still correctly evaluates to true since the small content plus inset still falls short of the scroll length threshold. Now I'm working through the anchored end space scenario, where padding might be double-counted between contentInsetEnd and stylePaddingEnd during content sizing, and tracing how ChatView's scroll handling treats citationPositioning versus anchoring-new-turn states to see if onIsAtEndChange gets skipped appropriately.

16. git_tools — const onIsAtEndChange

💭 I'm checking the onIsAtEndChange handler — it correctly suppresses the pill and returns early when actively following, so a spurious false doesn't break the live-follow behavior. Now I'm considering what target maintainScrollAtEnd actually pins to when scrolling to the end during streaming. At the hard bottom the geometry calculation resolves cleanly to zero, so that's fine. The more important question is around the ChatView viewportIsAwayFromEnd check, which now uses a tighter 40px band relative to the visible edge above the composer rather than the old near-hard-bottom band — I need to figure out whether, while following, the pinned position actually sits at that visible edge or at the true scroll hard-bottom beneath the composer. Scrolling to end places the content bottom right at the viewport bottom, meaning the real visible content edge (accounting for inset) lines up exactly with the boundary above the composer, so geometry stays at zero and the band logic remains valid. This also means the effect no longer needs to depend on composerOverlayHeight, which is actually an improvement since the listener won't reattach on composer resizes. One thing I want to check for regression is the removed isAtEnd short-circuit — specifically the case where geometry is present but content is shorter than the viewport with a large inset. I also want to flag a potential issue with anchoredEndSpace during the "anchoring-new-turn" phase: if the padding for anchoredEndSpace and the contentInsetEnd both get counted in the content size calculation, geometry could overshoot by the anchoredEndSpace size. I should follow up with the researcher to confirm whether anchoredEndSpace is double-counted in both stylePaddingEnd and contentInsetEnd. Concluding both old and new versions behave the same during anchoring since anchored end space is large enough to stay false regardless. Now I want to verify the test file change doesn't remove the only coverage for the inset fallback case — I should check whether the resolveTimelineIsAtEnd({ isAtEnd: true }) assertion is still present to confirm test consistency.

17. browse_code — apps/web/src/components/chat/MessagesTimeline.test.tsx

💭 Tests look updated and consistent now. Since I'm removing the second parameter, I should check if there are other call sites passing two arguments to resolveTimelineIsAtEnd by searching for its usages.

18. git_tools — resolveTimelineIsAtEnd

💭 With all call sites updated, I'm considering the edge case where content fits above the composer but the spacer still pushes contentLength slightly past scrollLength—checking whether the threshold comparison still correctly avoids showing the pill in that scenario. Working through the numbers, the last 50px are genuinely hidden under the composer, so the pill is legitimate here. I also need to check whether the timeline footer's height factors into both scroll length and content length consistently, since it's a real layout element, not just spacer padding—so I should grep for how TIMELINE_LIST_FOOTER is used to confirm it doesn't double up with composer clearance.

19. git_tools — TIMELINE_LIST_FOOTER =

💭 The footer size fits fine within the band. Now I'm tracing through how removing the contentInsetEndAdjustment dependency affects handleScroll's identity in MessagesTimeline — since it changes less often now, the rAF effect that depends on it re-runs less frequently, which means when the composer height changes, isAtEnd may no longer get recomputed the way it previously did. When the composer grows without the user scrolling, the scroll container's geometry effectively shifts but no scroll event fires to trigger recalculation, and since handleScroll no longer depends on the inset, the previous mechanism that used to catch this case via changed callback identity is gone. I'm working through whether the layout effect that scrolls to end while following covers the "following" case, and what happens to the pill state when the user isn't following and the composer grows. Working through more scenarios: if the composer shrinks while the pill is showing near the boundary, the user might now be within the threshold and should re-arm follow, but nothing triggers a recompute until the next scroll event happens. This confirms the bug: removing the contentInsetEndAdjustment dependency from handleScroll eliminated the only trigger that recomputed end-state on composer resize, while the new formula actually depends on inset — so stale pill/follow states can now occur. I'm checking whether any other recompute path exists — the useEffect only depends on handleScroll and rows.length, neither of which react to composer height changes. The patch's own comment noting "size-only changes may not emit a scroll event" confirms this gap, though I should check whether scrollTop clamping when content shrinks/grows might still trigger a scroll event naturally. Sending re-arms the follow behavior, but deleting text so the composer shrinks leaves the pill stale until a scroll event fires. This does seem like a genuine behavioral gap introduced by making resolveTimelineIsAtEnd inset-dependent without a recompute trigger on composer height changes—worth flagging as a suggestion rather than a hard violation. Confirming the inset dependence: since new distance-to-end now includes the composer height in its formula, the previous dependency that seemed redundant is actually now load-bearing, so I need to keep a recompute path tied to contentInsetEndAdjustment changes. I'm checking whether ChatView already recomputes when composerOverlayHeight changes elsewhere—scanning the callback sites again to see if scrollToEndClearance handles this dependency. Looking at MessagesTimeline's layout effect, it only calls scrollToEnd when the overlay grows and we're following the end—it's not a general recompute trigger. So the real gap is that nothing reruns the end-visibility calculation when the composer resizes now that the dependency was dropped from handleScroll. The fix would be adding contentInsetEndAdjustment back into the effect dependencies so handleScroll reruns whenever the composer height changes. Adding the dependency back would restore old behavior but might trip exhaustive-deps lint rules that flag unnecessary effect dependencies. A safer route is a separate effect keyed on that value which explicitly calls handleScroll when it changes. I'll present this as a diff on the effect rather than a full config rewrite. Since there's a genuine finding here, I lean toward flagging this as a failure per the check's default behavior, though I should weigh how confident I am in the staleness risk before finalizing — I want to double-check whether LegendList actually emits an onScroll event when the content dimensions change. Looking at the patch region, there's a recompute of getIsAtEnd tied to inset adjustments on web, and I see previousContentInsetEndAdjustmentRef in the patched list internals. I want to trace whether the list reacts to inset changes by triggering scroll or emitting related events.

20. browse_code — patches/@[email protected]

💭 I want to check how LegendList handles changes to contentInsetEndAdjustment, particularly the previousContentInsetEndAdjustmentRef pattern, so I'm going to look into the upstream behavior for that.

21. browse_code — In @legendapp/list 3.3.5 (as patched in patches/@[email protected]), what happens when the contentInsetEndAdjustment prop value changes at runtime on web? Specifically: (a) does the list call checkAtBottom / recompute isAtEnd, (b) does it invoke the consumer's onScroll handler, and (c) what does previousContentInsetEndAdjustmentRef do? I want to know whether a consumer's onScroll-based end-distance computation is re-run when only the inset changes (no user scroll).

💭 This confirms it: with the new inset-dependent formula, the derived end state goes stale whenever insets change, especially composer shrink on web without a synthetic scroll, or when the user's scrolled away from the end. The PR dropped the dependency that used to trigger recompute on inset changes, so if the user scrolls up (pill appears) and then the composer shrinks—say from deleting a draft line or dismissing a banner—the last row becomes visible again and geometry says we're at the end, but nothing re-triggers to catch that. I've checked the growth direction too: if the user's already scrolled away, the pill is already visible; if they're at end and following, the overlay-growth handler scrolls to keep them there, and liveFollow gets re-armed via the isAtEnd change handler. So shrink is the real gap. The fix belongs on the changed deps line in MessagesTimeline.tsx, explaining that end distance is now inset-dependent and needs the contentInsetEndAdjustment change to retrigger the rAF effect—I'm working out a diff to propose there. For the actual fix, I'll add contentInsetEndAdjustment to the effect's dependency array so it reruns when the inset changes, though I could alternatively suggest just keeping it in handleScroll's deps as the more minimal revert. This is a narrow but real inconsistency—a pill can go stale in this edge case—so I'll flag it as a single finding rather than let it slide. Since the guidance is that this check defaults to failure unless everything's clear, and I do have a legitimate finding here, I'll report it as a failure rather than hedge with neutral. Time to post the review.

22. modify_pr — Submitted review with 1 inline comment


Agent Credits: 428 credits

Token usage: 84 uncached input · 3,015,426 cache-read input · 253,266 cache-write input · 39,151 output
Agent Credits may also include non-token charges from external tools such as web research.