Skip to content

fix(web): scroll-to-end pill keeps one gap above the composer

MacroscopeApp / Macroscope - UI Consistency failed Aug 24, 2026 in 4m 23s

UI Consistency: 1 issue found

apps/web/src/components/ChatView.tsx — shoulderTabReserve measures surface.top - form.top, which spans more than the shoulder-tab band. The composer top drawer (approvals / pending questions / plan prompt) and ComposerTasksDrawer are siblings rendered before the div.relative containing [data-chat-composer-main-surface], so their height is subtracted from scrollToEndClearance. When a top drawer is attached and hasWideShoulderTab is false (no task steps — the common case while a blocking drawer is up), the z-30 "Scroll to end" pill is pulled down over the drawer content. Suggested fix: measure from the .chat-composer-shoulder-tab element, which keeps the reserve at the 28px band and returns 0 when no tab is rendered.

No other findings: the left-5.5 / right-5.5 / right-30 tab offsets are valid Tailwind v4 spacing utilities and align with the composer's --chat-composer-drawer-inset: 1.375rem; the MAX_TASK_SEGMENTS guard and its test are consistent with the existing segment 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.

Checks performed:

  • Verified [data-chat-composer-form="true"] / [data-chat-composer-main-surface="true"] DOM nesting in apps/web/src/components/chat/ChatComposer.tsx (form → top drawer → tasks drawer → div.relative → shoulder tabs + main surface) and the hasShoulderTab && "pt-7" band.
  • Confirmed the composer overlay measured by the useLayoutEffect wraps the whole form, so overlay height includes any attached drawer.
  • Confirmed the moved useLayoutEffect remains unconditional within ChatViewContent (no early return between its old and new position), so hook order is preserved.
  • Confirmed Tailwind v4 (tailwindcss@^4, @tailwindcss/vite) generates fractional/arbitrary spacing values (h-5.5, min-w-5.5 already used in ui/badge.tsx), so left-5.5, right-5.5, and right-30 emit real utilities.
  • Confirmed ComposerStashBadge returns null at count === 0 and ComposerTasksBadge returns null at totalSteps <= 0, so the presence of .chat-composer-shoulder-tab matches the pt-7 condition, making the suggested tab-based measurement equivalent in the intended cases.
  • Secondary note (not reported separately): hasWideShoulderTab is derived in ChatView and does not account for composer-local tab visibility (tasksDismissedForActiveTurn, collapsed mobile, external drawer), so after the user dismisses tasks the pill keeps the full-height clearance. The suggested fix makes this benign rather than requiring the duplicated state to be threaded through.

🔍 Agent activity — 17 tool calls

💭 I'm going through the diff in detail: it adds a helper in ChatView.tsx that checks composer form and surface data attributes to compute shoulder tab clearance, adjusts badge positioning offsets for the stash and tasks badges, and bumps the max task segments constant to 10. I need to verify whether these Tailwind spacing values like right-5.5 and right-30 are valid depending on the Tailwind version in use, since v3 wouldn't support them by default. I also want to confirm the relevant data attributes actually get rendered in the DOM, otherwise the clearance calculation would always be zero, and double-check that moving the useLayoutEffect hook doesn't violate React's hooks rules given any early returns nearby.

1. git_tools — data-chat-composer-main-surface|data-chat-composer-form

2. git_tools — tailwindcss

💭 I'm confirming that Tailwind v4's spacing scale supports arbitrary fractional multiples like right-5.5 or right-30, since the calc-based theme allows these values to generate properly. I should check whether the repo uses similar .5 fractional spacing elsewhere beyond the built-in defaults, and verify that the data attributes actually show up in the DOM within ChatComposer. I also want to confirm the shoulderTabReserve calculation (surface top minus form top) is measuring the reserved space correctly, and I'm now looking through ChatView.tsx for any early returns that could affect hook ordering.

3. git_tools — -5.5|-30 |right-30|w-5.5|p-5.5

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

💭 Tailwind v4 supports fractional spacing values like 5.5 through its calc-based spacing scale, so using such values for the shoulder tab offset is valid, and I'm adjusting the tabs from left-4/right-4 to 5.5 for better positioning. Checking the stash badge (right-4 to right-5.5) and hasTrailingShoulder (right-28 to right-30), there's a slight 0.125rem inconsistency in the gap between tabs, but that's minor and aesthetic rather than a real bug. Next I want to look at ChatView's shoulderTabReserve and how the hook was moved to see if anything else needs adjusting there.

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

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

💭 The scroll-to-end button used to sit above the entire overlay including the shoulder tab, but now when there's no wide shoulder tab present, I can subtract that reserved space so the button drops lower into what's mostly empty area. I'm spotting two potential issues though: the wide-shoulder check only accounts for the tasks tab and ignores the stash badge on the right, but more importantly, if the tasks tab actually renders whenever progress is non-null (even with empty steps), then the wide-shoulder flag could be false while the tab still spans full width — causing the scroll-to-end button to overlap it. I need to check the actual render site to confirm this. There's also a question of whether querying the overlay DOM for the composer surface via querySelector is reliable here. I'm also considering whether the reserve height calculation properly accounts for an expanded tasks drawer sitting above the surface inside the form — if the drawer isn't factored in, the clearance math would be off and the button could overlap it. I need to look at ChatComposer.tsx's structure to see how the form, surface, and badge are actually laid out.

7. git_tools — ComposerTasksBadge|ComposerTasksDrawer|ComposerStashBadge|data-chat-composer-form|data-chat-composer-main-surface|chat-composer-top-drawer

8. git_tools — activeComposerTaskSteps|activeComposerTasksProgress|composerOverlayHeight|scrollToEndClearance

💭 I want to check ChatComposer's tab rendering logic to see if the tasks tab renders when activeTaskSteps is null but progress data is present, so I'll search the file for references to activeTasksProgress and activeTaskSteps.

9. git_tools — activeTaskSteps|activeTasksProgress|tasksTab|hasTrailingShoulder|placement=

💭 The grep failed, likely due to a binary-like file. I'll switch to code_research to locate where ComposerTasksBadge is used within ChatComposer instead.

10. browse_code — In apps/web/src/components/chat/ChatComposer.tsx, show the JSX where ComposerTasksBadge, ComposerTasksDrawer and ComposerStashBadge are rendered, including the conditions (e.g. whether the tasks shoulder tab renders when activeTaskSteps is null), the element carrying data-chat-composer-form="true" and the element carrying data-chat-composer-main-surface="true", and their nesting order. Quote the code with line numbers.

💭 I realize the shoulder tab reserve calculation depends on whether the top drawer or tasks drawer renders before the relative div — if so, the full drawer height gets folded into that spacing measurement, which could throw off the wide-shoulder-tab detection. If a top drawer (like approvals or pending questions) is showing without tasks, hasWideShoulderTab would be false, so clearance becomes height minus drawer height, which could push the scroll-to-end button to overlap the drawer content itself. That seems like a real bug — the reserve helper is meant to represent just the pt-7 padding, but the actual measurement conflates that with any drawer content rendered above the surface. Then there's the tasks case: when dismissed, the shoulder tabs disappear but ChatView still sees progress/steps as non-null, so hasWideShoulderTab stays true and clearance stays at full height — meaning the button floats about 28px higher than it needs to. That's minor cosmetic drift rather than an overlap bug. I still need to pin down hasShoulderTab's exact definition and confirm whether composerOverlayElement is actually an ancestor of the form, since that determines whether the drawer height truly factors into the reserve calculation.

11. browse_code — apps/web/src/components/chat/ChatComposer.tsx

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

💭 Now I see that the overlay spans the entire composer including the top drawer, so shoulderTabReserve actually measures from form.top to surface.top, capturing the shoulder-tab padding, the top drawer height (approvals/pending input/plan banner), and the ComposerTasksDrawer height all together. I'm working through the cases where this measurement could overlap with content, starting with when the composer's top drawer is visible. If the top drawer is showing (an approval prompt, pending question, or plan follow-up) but there's no task progress or steps, hasWideShoulderTab is false, so clearance ends up as overlayHeight minus drawerHeight, which means the "Scroll to end" pill can render right on top of that drawer content — this looks like a real regression since drawers can get fairly tall. When tasks progress and steps are both present alongside the drawer, hasWideShoulderTab is true and clearance uses the full height, so that combination is fine. I'm also noting that hasWideShoulderTab in ChatView doesn't account for tasksDismissedForActiveTurn or showShoulderTabs, meaning this state is being derived separately from the composer's internal state — an ownership inconsistency, though it doesn't matter when shoulder tabs are hidden entirely since the reserve is zero in that case. I'm also checking a second potential issue: MAX_TASK_SEGMENTS caps at 10, so segments get dropped when there are more than 10 steps, and the test expects specific width classes to not appear when the segment list is empty (returning null instead). That part seems fine, but I'm wondering whether silently dropping segments when there are many steps (like 24) creates a UI consistency problem, since each segment would need to be very thin to fit. Now I'm looking at whether the right offset changes (right-5.5 vs right-30) are applied consistently across all shoulder tab elements, or if there's a mismatch somewhere else, like in index.css around lines 860-1130 where shoulder tab styles, mask geometry, or pseudo-element positioning might have hardcoded offset values that no longer match the updated layout.

13. browse_code — apps/web/src/index.css

💭 The drawer inset value works out to exactly 5.5 spacing units, so shifting the shoulder tabs to left-5.5/right-5.5 aligns them with that inset, which matches the PR's intent. I'm now checking the math on the stash tab's width and gap to confirm the tasks tab's right offset lines up correctly too. I also want to confirm decimal Tailwind spacing values actually work here — I find existing badge.tsx classes like h-5.5 and min-w-5.5 already in the codebase, which confirms decimal spacing utilities are supported. Now I'm turning to check whether the scroll-to-end pill might overlap with anything after this change. Tracing the pill's positioning logic against the composer overlay height and drawer reserve: when there's no shoulder tab, the reserve stays zero and behavior matches before, but when the stash queue is non-empty without tasks, the reserve becomes 28 and the pill shifts down into the shoulder band alongside the stash tab, which seems like the intended behavior — though I want to check how the pill's own padding interacts with this new position. Doing the math, it's borderline but should just barely clear on a narrow mobile screen. But there's another scenario to worry about: when a top drawer (approval prompt, pending question, plan follow-up) is showing instead of shoulder tabs, the clearance calculation doesn't account for the drawer height, so the pill could end up overlapping that drawer content. Since the pill's container sits at z-30 versus the overlay's z-20, the pill actually paints over the drawer — and since users often scroll away during an approval prompt (which is when showScrollToBottom triggers), this seems like a real visual regression from the diff, distinct from the tasks-drawer case which is fine since that uses the full-height shoulder tab path. The real issue is that shoulderTabReserve measures the entire gap between the form top and the main surface — including any top drawer, not just the 28px shoulder band — so when a top drawer is attached without the wide tasks tab active, the pill gets pulled down over it. The fix is to measure just the shoulder tab element itself rather than the whole form, so I'm sketching a helper that queries the shoulder tab and main surface directly to compute the reserve. There's also a second issue: hasWideShoulderTab duplicates the composer's internal visibility logic (dismissed tasks, external drawers, mobile collapse, top/tasks drawer states), which causes mismatches — e.g. when a user dismisses tasks for the turn, ChatView still thinks the wide tab is showing and reserves full height even though only the narrow stash tab remains. A cleaner fix might be to derive the wide-tab condition from the DOM itself, like checking for a .chat-composer-tasks-tab element in the overlay, rather than duplicating state at the ChatView level. Though I need to think about whether a ResizeObserver would even fire when the tab appears without a size change, since that could break the detection timing. Actually the height changes when the tab first appears (padding gets added), but if the padding is already there from another tab and the tasks tab appears, there'd be no size change and no callback, leaving stale clearance — which is exactly why the current state dependency exists. So DOM-only detection is fragile here, and since ChatView can't observe composer-internal dismissal either, I'll keep the state approach but flag this as a secondary note rather than a blocking issue, focusing my main comment on the drawer inclusion problem. I want to double-check the top-drawer overlap math and confirm what's actually rendered inside the form versus outside it (like the external drawer slot), so I'll look at the surrounding render lines to verify the reserve calculation is sound.

14. browse_code — apps/web/src/components/chat/ChatComposer.tsx

💭 I've confirmed the drawer height is properly included in the overlay measurement since the overlay contains the form. I'll add an inline comment targeting lines 1247-1255 in ChatView.tsx where the helper function was added, suggesting a fix that measures from the correct reference point. I'm defining a replacement helper function that measures the shoulder tab band by comparing the top position of the tab element against the main surface, returning zero when either is missing — this naturally handles the dismissed-tasks case too. I should also verify whether the stash badge component actually renders null when count is zero, since if it renders anyway while shoulder tabs are shown, that could affect the reserve calculation.

15. browse_code — apps/web/src/components/chat/ComposerStashBadge.tsx

16. browse_code — apps/web/src/components/chat/ComposerStashBadge.tsx

💭 I'm confirming the shoulder tab presence lines up correctly with the pt-7 condition, including the tasks tab edge case where zero total steps returns null—so the fix holds up as robust and consistent. Now I'm weighing whether this should actually flag as a failing consistency check.

17. modify_pr — One concrete regression in the new clearance measurement — see the inline comment on shoulderTabReserve. The left-5.5 / right-5.5 / right-30 shoulder-tab offsets line up with the composer's --chat-composer-drawer-inset: 1.375rem, and the MAX_TASK_SEGMENTS guard looks fine.


Agent Credits: 157 credits