Skip to content

[Feature] Add Message Scroller component#447

Merged
cirdes merged 5 commits into
mainfrom
feat/message-scroller-component
Jun 30, 2026
Merged

[Feature] Add Message Scroller component#447
cirdes merged 5 commits into
mainfrom
feat/message-scroller-component

Conversation

@djalmaaraujo

@djalmaaraujo djalmaaraujo commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Description

Ports the shadcn Message Scroller to RubyUI — a chat transcript scroller that follows the live edge, anchors new turns near the top, preserves position when older messages load, and jumps to the latest message.

Stacked on #446 (Message) which stacks on #445 (Bubble). Base is feat/message-component; this PR shows only the scroller diff. Retarget down the stack as the lower PRs merge.

Reference:

Our own code — no external lib

shadcn delegates the behavior to a closed React primitive (@shadcn/react/message-scroller). This is a from-scratch Stimulus controller (ruby-ui--message-scroller) — the first JS component in this stack — replicating the documented behaviors in vanilla JS:

  • autoScroll follow-edge — pins to the bottom while the reader is at the live edge; releases on wheel / touch / keyboard / scrollbar away; re-engages on jump.
  • scrollAnchor — settles an appended turn near the top, keeping a peek of the previous item (previous_item_peek, default 64).
  • defaultPosition — open at end / start / last-anchor.
  • preserveOnPrepend — hold the visible row when history loads in above.
  • Public API (built for future streaming / ActionCable): scrollToEnd, scrollToStart, scrollToMessage. New rows are picked up automatically via MutationObserver (childList + characterData), so streamed tokens keep the view pinned.
  • rAF-based smooth scrolling — native scrollTo({ behavior: "smooth" }) is unreliable on a contained viewport, so the controller animates scrollTop itself; honors prefers-reduced-motion.
  • Accessibility — content is role="log" + aria-relevant; the button has an sr-only label and is removed from the tab order while inert.

Parts

MessageScrollerProvider (owns scroll state) · MessageScroller (frame) · MessageScrollerViewport · MessageScrollerContent · MessageScrollerItem (scroll_anchor:, message_id:) · MessageScrollerButton (direction:).

Reuses Message + Bubble; dependencies.yml declares both.

Out of scope (vs shadcn)

The full primitive also does row-virtualization and exposes visibility/animation hooks. This port keeps the documented scroll behaviors and drops content-visibility virtualization, which made scrollHeight unstable and fought reliable programmatic scroll-to-end. The demo is a self-contained thread (no ai-sdk); it's wired so a future ActionCable/streaming source can drive it through the public API.

Testing

  • cd gem && bundle exec rake → tests + StandardRB green.
  • Verified in-browser at /docs/message_scroller: opens at the live edge, scroll up reveals the jump-to-latest button, clicking it smooth-scrolls back to the bottom and hides the button. Scroll-command buttons drive the viewport from outside the frame.

Screenshots

Add before/after of /docs/message_scroller.


Summary by cubic

Adds a Message Scroller and an Empty state. The scroller follows the live edge, anchors new turns, preserves position when history loads, and provides a jump-to-latest button. Implemented with a Stimulus controller, no external libraries.

  • New Features

    • Components: MessageScrollerProvider, MessageScroller, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton; Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent.
    • Behaviors: auto-follow with release on user scroll; turn anchoring with previous_item_peek; default positions (end/start/last-anchor); preserveOnPrepend.
    • API: scrollToEnd, scrollToStart, scrollToMessage; streaming picked up via MutationObserver.
    • UX: rAF smooth scrolling; honors reduced motion; live-region and focus-safe button.
    • Docs: new /docs/message_scroller page with an interactive chat window demo using Empty and a docs-only message-scroller-chat controller; route/nav/tests/registry updated; generator dependencies.yml lists Message and Bubble.
    • Fixes: gate anchoring behind auto-follow; button action renamed to jump; direction-aware button visibility (start vs end) with multiple targets; guard last-anchor when content is missing; include row gap in prepend preservation; treat only direct content children as rows (subtree mutations count as streaming).
  • Migration

    • Wrap your message list in MessageScrollerProvider and use Viewport > Content > Item.
    • Mark the first row of a turn with scroll_anchor; use MessageScrollerButton to jump to the latest.
    • No external dependencies; relies on Message and Bubble.

Written for commit 7e4e3ed. Summary will update on new commits.

Review in cubic

@djalmaaraujo djalmaaraujo requested a review from cirdes as a code owner June 30, 2026 16:15

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

5 issues found across 21 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread docs/app/javascript/controllers/ruby_ui/message_scroller_controller.js Outdated
Comment thread docs/app/javascript/controllers/ruby_ui/message_scroller_controller.js Outdated
Comment thread docs/app/javascript/controllers/ruby_ui/message_scroller_controller.js Outdated
Comment thread gem/lib/ruby_ui/message_scroller/message_scroller_controller.js

@cirdes cirdes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@djalmaaraujo, please take a look on cubic-dev comments and ask for review again

Base automatically changed from feat/message-component to main June 30, 2026 20:04
@djalmaaraujo

Copy link
Copy Markdown
Contributor Author

Addressed all 5 cubic findings in 205355d8 (registry rebuilt):

  • P1 — anchor scroll yank: anchored-turn scrolling is now gated behind autoScroll && following, same as end scrolling. A new turn no longer pulls a reader who scrolled up to older content; the jump button surfaces it instead.
  • P2 — button ignores direction: renamed the action jumpToEnd → jump and it branches on data-direction, so a direction: :start button jumps to the start.
  • P2 — last-anchor throws without content: guarded with hasContentTarget (the Stimulus target getter throws rather than returning undefined, so optional chaining didn't help).
  • P2 — prepend under-compensates by gap: prepend preservation now adds the computed flex row-gap per inserted row, so the visible row stays put.
  • P2 — subtree prepend false positive: only direct children of the content element count as transcript rows; markup mutated inside a message is treated as streaming, not history.

@cirdes ready for another look.

@djalmaaraujo djalmaaraujo requested a review from cirdes June 30, 2026 20:07

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 5 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread gem/lib/ruby_ui/message_scroller/message_scroller_controller.js
@djalmaaraujo djalmaaraujo force-pushed the feat/message-scroller-component branch from 205355d to cdec99f Compare June 30, 2026 20:26
Port the shadcn Empty component: a centered empty-state surface for when
there is no data or content. Parts: Empty, EmptyHeader, EmptyMedia
(default/icon variants), EmptyTitle, EmptyDescription, EmptyContent.

Translates shadcn's cn-empty-* CSS layer to Tailwind v4 utilities. No JS.

Docs page, route, controller, menu, site_files and MCP registry updated.
Port the shadcn Message Scroller: a chat transcript scroller that follows
the live edge, anchors new turns near the top, and jumps to the latest
message. Built on top of Message (#446) and Bubble (#445).

shadcn delegates to a closed React primitive (@shadcn/react); this is a
from-scratch Stimulus controller (ruby-ui--message-scroller) — our own
code, no external lib:

- autoScroll follow-edge: pins to the bottom while the reader is there,
  releases on wheel/touch/keyboard/scrollbar away, re-engages on jump.
- scrollAnchor: settles an appended turn near the top keeping a peek of
  the previous item (previous_item_peek).
- defaultPosition: open at end / start / last-anchor.
- preserveOnPrepend: hold the visible row when history loads in above.
- Public API for streaming/ActionCable: scrollToEnd/scrollToStart/
  scrollToMessage; new rows are picked up via MutationObserver.
- rAF-based smooth scrolling (native smooth is unreliable on a contained
  viewport), honors prefers-reduced-motion.
- a11y: content role=log + aria-relevant, button sr-only label, button
  removed from tab order while inert.

Parts: MessageScrollerProvider, MessageScroller, MessageScrollerViewport,
MessageScrollerContent, MessageScrollerItem, MessageScrollerButton.

dependencies.yml: message_scroller depends on Message + Bubble. MCP
registry, docs page, route, controller, menu and site_files updated.
- Gate anchored-turn scrolling behind autoScroll/following, so a new turn
  never yanks a reader who scrolled up to older content (P1).
- Scroll button honors data-direction: a start-direction button now jumps
  to the start instead of the end (renamed action jumpToEnd → jump) (P2).
- Guard last-anchor opening position with hasContentTarget; the Stimulus
  target getter throws rather than returning undefined (P2).
- Include the flex row gap in prepend preservation so the visible row no
  longer drifts down by one gap per history insertion (P2).
- Only treat direct content children as transcript rows; markup mutated
  inside a message (subtree) is handled as streaming, not history (P2).

Rebuilt MCP registry.
…y state

Add the shadcn-style chat window as the hero example: a Card with an Empty
state until the first message, then a scrolling transcript that follows the
live edge, plus an input footer. A docs-only Stimulus demo harness
(message-scroller-chat) clones server-rendered user/assistant templates on
send so the scroller's autoscroll/anchoring is demonstrated live — standing
in for a real ActionCable/streaming source. Uses the new Empty component.
@djalmaaraujo djalmaaraujo force-pushed the feat/message-scroller-component branch from cdec99f to fb46ef4 Compare June 30, 2026 20:47
@djalmaaraujo djalmaaraujo changed the base branch from main to feat/empty-component June 30, 2026 20:47
@djalmaaraujo

Copy link
Copy Markdown
Contributor Author

Added the faithful shadcn-style chat window as the hero example, addressing the gap raised in review:

  • Empty state — uses the new Empty component (split into its own PR [Feature] Add Empty component #448). The window shows it until the first message.
  • Full chat windowCard (header + content + footer) with an input + send button, matching the shadcn demo layout.
  • Interactive — a docs-only Stimulus harness (message-scroller-chat) clones server-rendered user/assistant <template> rows on send, so autoscroll + turn-anchoring are demonstrated live (standing in for an ActionCable/streaming source, which is the intended real-world driver).

This PR now stacks on #448 (Empty) — base retargeted to feat/empty-component. Merge order: #448#447.

The earlier "empty state / full chat window out of scope" note no longer applies — both are now included.

updateButton now derives each button's active state from its own
data-direction — an end button activates when away from the bottom, a
start button when away from the top — and iterates all button targets.
Previously a start-direction button used end logic, so it was inert at
the live edge and showed at the wrong end. (cubic P2)
@djalmaaraujo

Copy link
Copy Markdown
Contributor Author

Fixed in 7e4e3edf: updateButton() is now direction-aware and iterates all button targets. An end button activates when away from the bottom; a start button activates when away from the top (isAtStart()), instead of both keying off !isAtEnd(). Registry rebuilt.

Base automatically changed from feat/empty-component to main June 30, 2026 20:58
@cirdes cirdes merged commit 9f3de7d into main Jun 30, 2026
8 checks passed
@cirdes cirdes deleted the feat/message-scroller-component branch June 30, 2026 21:02
@djalmaaraujo djalmaaraujo mentioned this pull request Jul 1, 2026
3 tasks
djalmaaraujo added a commit that referenced this pull request Jul 1, 2026
Bump RubyUI to 1.5.0 (minor: new components since v1.4.0).

- gem/lib/ruby_ui.rb → 1.5.0; regenerate gem/ and docs/ Gemfile.lock
- docs home hero badge → headline features (Bubble, Message, Empty)
- rebuild mcp/data/registry.json

Highlights since v1.4.0:
- New components: Bubble (#445), Message (#446), Message Scroller (#447), Empty (#448)
- Port hover_card & context_menu to Floating UI, drop Popper.js (#438)
- Bug fixes: Dialog closed-state + docs controller (#458), DropdownMenu z-index (#440)
djalmaaraujo added a commit that referenced this pull request Jul 1, 2026
Bump RubyUI to 1.5.0 (minor: new components since v1.4.0).

- gem/lib/ruby_ui.rb → 1.5.0; regenerate gem/ and docs/ Gemfile.lock
- docs home hero badge → headline features (Bubble, Message, Empty)
- rebuild mcp/data/registry.json

Highlights since v1.4.0:
- New components: Bubble (#445), Message (#446), Message Scroller (#447), Empty (#448)
- Port hover_card & context_menu to Floating UI, drop Popper.js (#438)
- Bug fixes: Dialog closed-state + docs controller (#458), DropdownMenu z-index (#440)
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.

2 participants