Skip to content

fix(chat): pin all chat panels to bottom across async layout shifts#598

Open
b-client-vm wants to merge 3 commits into
spacedriveapp:mainfrom
brendandebeasi:fix/594-chat-scroll-bottom
Open

fix(chat): pin all chat panels to bottom across async layout shifts#598
b-client-vm wants to merge 3 commits into
spacedriveapp:mainfrom
brendandebeasi:fix/594-chat-scroll-bottom

Conversation

@b-client-vm
Copy link
Copy Markdown

@b-client-vm b-client-vm commented May 10, 2026

Closes #594.

Why

Two chat panels in the dashboard had the same scroll-pinning bug, with their own copies of the broken logic:

  • Cortex chat panel (CortexChatPanel.tsx) — the case called out in Chat sometimes doesn't scroll all the way to bottom on new message #594. Effect on [messages.length, isStreaming, toolActivity.length] missed tool-result expansion (status flip without length change), the ThinkingIndicator toggle, async Markdown reflow, and `behavior: "smooth"` raced layout shifts.
  • Portal chat (PortalTimeline.tsx) — same pattern. Effect on `[visibleItems.length, isTyping]` missed tool-result expansion and per-message copy-button mount (`MessageBubble onCopy`); send-time effect used `behavior: "smooth"` inside `requestAnimationFrame`.

The channel timeline (ChannelDetail.tsx) uses flex-col-reverse for a CSS-only pin and didn't need the hook.

How

New hook `useStickToBottom(scrollRef, contentRef)`:

  • A `ResizeObserver` on the content element catches every height change uniformly — no dep-array enumeration.
  • A `scroll` listener tracks "is the user near the bottom" (within 64px). Re-pinning only happens when this is true, so scrolling up to read history is preserved.
  • Snaps to bottom synchronously plus on the next two animation frames. The follow-up snaps catch growth that fires after the RO callback returns: scrollbar appearing and narrowing content (extra wrap row), web-font swap, late Markdown layout (images, code blocks), or a sibling element mounted a frame later (a per-message copy button is the case I caught testing).
  • Uses `behavior: "auto"`. If a later shift happens during the scroll, the observer just snaps us forward again on the next callback — no animation race.

Wired into both Cortex and Portal panels: refs on the existing scroll container + inner content div, drop the old `useEffect`s + the now-unused `messagesEndRef` / `previousLengthRef`. Portal keeps a small useEffect on `sendCount` that force-snaps to the bottom — preserves "I just sent, take me to my message" even when the user had scrolled up.

Validation

  • `tsc --noEmit` clean on all three files.
  • `vite build` passes.
  • Manual repro with a standalone harness (vanilla-JS port of the hook) verified all four Chat sometimes doesn't scroll all the way to bottom on new message #594 scenarios: initial mount at bottom, tool-result expansion re-pins, async growth re-pins, scroll-up preserves user position.
  • Tested live in the running dashboard — Portal chat now pins through streaming responses including the copy button mount.

Closes spacedriveapp#594.

The previous useEffect-based scroll missed several height-changing events:
tool result expansion (status flip without array length change), the
ThinkingIndicator toggle (derived state, not a dep), and async markdown
reflow (highlighter, fonts, image loads finish after the scroll has run).
behavior: "smooth" also raced the layout shifts and landed short.

Replaced with a ResizeObserver-based hook (useStickToBottom):

- Observes the messages content directly, so any height change re-pins
  uniformly — no need to enumerate dep-array entries per layout source.
- Tracks "near bottom" (within 64px) on scroll. New content only re-pins
  when the user is already at the bottom, so scrolling up to read history
  is preserved.
- Uses behavior: "auto" so layout shifts during the scroll can't make it
  land short — the next observed shift snaps us forward again.

Removes the old messagesEndRef sentinel; the ResizeObserver tracks the
content element's height directly.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 10, 2026

Review Change Stack
No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a2d75541-3eb0-403f-bd4a-0832247b7310

📥 Commits

Reviewing files that changed from the base of the PR and between f89fbc2 and 3d714f2.

📒 Files selected for processing (1)
  • interface/src/components/portal/PortalTimeline.tsx

Walkthrough

The PR replaces a useEffect/scrollIntoView approach with a new useStickToBottom hook that uses a ResizeObserver and a pinned-state heuristic, and integrates it into CortexChatPanel and PortalTimeline via dedicated scrollRef and contentRef, removing the previous bottom sentinel element.

Changes

Auto-Scroll Stick-to-Bottom Behavior

Layer / File(s) Summary
useStickToBottom Hook
interface/src/hooks/useStickToBottom.ts
Defines NEAR_BOTTOM_PX, exports useStickToBottom(scrollRef, contentRef), initializes isPinnedRef, forces initial bottom snap across RAFs, adds a passive scroll listener to update pinned state, uses ResizeObserver on contentRef to reapply scrollTop = scrollHeight when pinned, and cleans up listeners/observer.
CortexChatPanel Integration
interface/src/components/CortexChatPanel.tsx
Imports useStickToBottom, creates scrollRef and contentRef, calls useStickToBottom(scrollRef, contentRef), attaches refs to the messages container elements, and removes the previous bottom sentinel <div ref={messagesEndRef} /> and its effect-based scrollIntoView.
PortalTimeline Changes
interface/src/components/portal/PortalTimeline.tsx
Adds contentRef, calls useStickToBottom(scrollRef, contentRef), removes prior smart auto-scroll/rAF behavior, and pins immediately to bottom on sendCount by setting scrollTop = scrollHeight.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: implementing a pin-to-bottom behavior for chat panels across async layout shifts, which is the core objective of this PR.
Description check ✅ Passed The description is directly related to the changeset, explaining the problem, the solution via useStickToBottom hook, and how it's applied to both Cortex and Portal chat panels.
Linked Issues check ✅ Passed The changes fully address issue #594's requirements by implementing a ResizeObserver-based hook that catches all height-changing events, preserves user scroll-up intent, and avoids smooth-scroll race conditions.
Out of Scope Changes check ✅ Passed All changes are within scope—the new useStickToBottom hook, updates to CortexChatPanel and PortalTimeline are all directly tied to fixing the chat scroll-pinning issue described in #594.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
interface/src/hooks/useStickToBottom.ts (1)

37-37: 💤 Low value

Optional: Make scrollTop assignment more explicit.

Setting scroll.scrollTop = scroll.scrollHeight works because browsers clamp to the maximum valid value (scrollHeight - clientHeight), but the intent would be clearer as:

-		scroll.scrollTop = scroll.scrollHeight;
+		scroll.scrollTop = scroll.scrollHeight - scroll.clientHeight;

This is purely a readability suggestion—the current code is functionally correct.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@interface/src/hooks/useStickToBottom.ts` at line 37, The current assignment
in useStickToBottom (setting scroll.scrollTop) should be made explicit: compute
the target as scroll.scrollHeight minus scroll.clientHeight and assign that
value to scroll.scrollTop, clamping with Math.max to avoid negative values
(i.e., use Math.max(0, scroll.scrollHeight - scroll.clientHeight)); update the
assignment at the line that currently sets scroll.scrollTop to
scroll.scrollHeight to use this explicit, clamped value for clarity.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@interface/src/hooks/useStickToBottom.ts`:
- Line 37: The current assignment in useStickToBottom (setting scroll.scrollTop)
should be made explicit: compute the target as scroll.scrollHeight minus
scroll.clientHeight and assign that value to scroll.scrollTop, clamping with
Math.max to avoid negative values (i.e., use Math.max(0, scroll.scrollHeight -
scroll.clientHeight)); update the assignment at the line that currently sets
scroll.scrollTop to scroll.scrollHeight to use this explicit, clamped value for
clarity.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 20842779-63b7-41e4-8bea-92ffdd8bec86

📥 Commits

Reviewing files that changed from the base of the PR and between ac52277 and 40c1182.

📒 Files selected for processing (2)
  • interface/src/components/CortexChatPanel.tsx
  • interface/src/hooks/useStickToBottom.ts

Brendan DeBeasi added 2 commits May 10, 2026 10:05
User reported: "sometimes the response text scrolls down, but still
doesn't expose the full response + copy button as it streams in." That
matches a category of bugs the single-pass scroll missed:

- Scrollbar appearing AFTER the initial scroll narrows the content's
  available width (non-overlay scrollbars), forcing one more line of wrap
  → content grows by ~1 row → we're now ~1 row above the new bottom.
- Web-font swap: fallback → loaded font widens text → reflow → 1 row taller.
- A hover-action button or toolbar mounted on the next frame (e.g. a
  message copy button) extends content past where we just scrolled.

ResizeObserver does fire for these, but only once. The settleToBottom
helper now snaps on the current frame plus the next two RAFs, which is
enough to absorb the typical 1-2 frame delay these layout shifts have.
isPinned is checked before each follow-up snap so a user scroll-up in
between cancels the chase.

Cheap to over-call — scrollTop = scrollHeight is a no-op once we're at
the bottom.
The portal chat (main agent conversation view) had the same scroll-pinning
bug as the Cortex chat panel from spacedriveapp#594, with its own copy of the logic:

- Effect on [visibleItems.length, isTyping] missed tool-result expansion
  and the per-message copy-button mount that fires when MessageBubble's
  onCopy renders.
- Effect on [sendCount] used behavior: "smooth" inside requestAnimationFrame,
  same animation-vs-layout-shift race the issue called out.

Replaced both with the same useStickToBottom hook used in CortexChatPanel,
plus a short useEffect that force-snaps to bottom on sendCount change
(preserves the "I just sent a message, take me to my message" behavior
even when the user had scrolled up).

Channel timeline (ChannelDetail.tsx) uses flex-col-reverse for a CSS-only
scroll pin and doesn't need this hook.
@b-client-vm b-client-vm changed the title fix(chat): pin Cortex chat to bottom across async layout shifts fix(chat): pin all chat panels to bottom across async layout shifts May 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chat sometimes doesn't scroll all the way to bottom on new message

1 participant