Skip to content

Commit 9f3de7d

Browse files
authored
[Feature] Add Message Scroller component (#447)
* [Feature] Add Empty component 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. * [Feature] Add Message Scroller component 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. * [Bug Fix] Message Scroller: address review feedback - 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. * [Documentation] Message Scroller: faithful chat-window demo with Empty 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. * [Bug Fix] Message Scroller: direction-aware scroll button visibility 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)
1 parent 1ca080d commit 9f3de7d

22 files changed

Lines changed: 1509 additions & 2 deletions

docs/app/components/shared/components_list.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def components
3737
{name: "Link", path: docs_link_path},
3838
{name: "Masked Input", path: masked_input_path},
3939
{name: "Message", path: docs_message_path},
40+
{name: "Message Scroller", path: docs_message_scroller_path},
4041
{name: "Pagination", path: docs_pagination_path},
4142
{name: "Popover", path: docs_popover_path},
4243
{name: "Progress", path: docs_progress_path},

docs/app/controllers/docs_controller.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,10 @@ def message
174174
render Views::Docs::Message.new
175175
end
176176

177+
def message_scroller
178+
render Views::Docs::MessageScroller.new
179+
end
180+
177181
def pagination
178182
render Views::Docs::Pagination.new
179183
end

docs/app/javascript/controllers/index.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import { application } from "./application"
77
import IframeThemeController from "./iframe_theme_controller"
88
application.register("iframe-theme", IframeThemeController)
99

10-
import ToastDemoController from "./toast_demo_controller"
11-
application.register("toast-demo", ToastDemoController)
10+
import MessageScrollerChatController from "./message_scroller_chat_controller"
11+
application.register("message-scroller-chat", MessageScrollerChatController)
1212

1313
import RubyUi__AccordionController from "./ruby_ui/accordion_controller"
1414
application.register("ruby-ui--accordion", RubyUi__AccordionController)
@@ -76,6 +76,9 @@ application.register("ruby-ui--hover-card", RubyUi__HoverCardController)
7676
import RubyUi__MaskedInputController from "./ruby_ui/masked_input_controller"
7777
application.register("ruby-ui--masked-input", RubyUi__MaskedInputController)
7878

79+
import RubyUi__MessageScrollerController from "./ruby_ui/message_scroller_controller"
80+
application.register("ruby-ui--message-scroller", RubyUi__MessageScrollerController)
81+
7982
import RubyUi__PopoverController from "./ruby_ui/popover_controller"
8083
application.register("ruby-ui--popover", RubyUi__PopoverController)
8184

@@ -117,3 +120,6 @@ application.register("ruby-ui--tooltip", RubyUi__TooltipController)
117120

118121
import SidebarMenuController from "./sidebar_menu_controller"
119122
application.register("sidebar-menu", SidebarMenuController)
123+
124+
import ToastDemoController from "./toast_demo_controller"
125+
application.register("toast-demo", ToastDemoController)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Controller } from "@hotwired/stimulus";
2+
3+
// Docs-only demo harness for the Message Scroller chat window.
4+
//
5+
// On submit it clones the server-rendered user/assistant <template> rows into
6+
// the scroller content and clears the empty state. The MessageScroller
7+
// controller observes the content and handles autoscroll/anchoring — this
8+
// controller only produces rows, the way a real ActionCable/streaming source
9+
// would. Connects to data-controller="message-scroller-chat".
10+
export default class extends Controller {
11+
static targets = ["content", "empty", "scroller", "input", "userTemplate", "assistantTemplate"];
12+
static values = {
13+
replies: { type: Array, default: [] },
14+
};
15+
16+
connect() {
17+
this.turn = 0;
18+
}
19+
20+
send(event) {
21+
event.preventDefault();
22+
const text = this.hasInputTarget ? this.inputTarget.value.trim() : "";
23+
if (!text) return;
24+
25+
this.reveal();
26+
this.appendRow(this.userTemplateTarget, text);
27+
this.inputTarget.value = "";
28+
29+
const reply = this.repliesValue[this.turn % this.repliesValue.length] || "Got it — thanks!";
30+
this.turn += 1;
31+
// Let the assistant reply land a beat later, like a streamed response.
32+
this.replyTimer = setTimeout(() => {
33+
this.appendRow(this.assistantTemplateTarget, reply);
34+
}, 500);
35+
}
36+
37+
reset() {
38+
if (this.replyTimer) clearTimeout(this.replyTimer);
39+
if (this.hasContentTarget) this.contentTarget.replaceChildren();
40+
this.scrollerTarget?.classList.add("hidden");
41+
this.emptyTarget?.classList.remove("hidden");
42+
this.turn = 0;
43+
}
44+
45+
reveal() {
46+
this.emptyTarget?.classList.add("hidden");
47+
this.scrollerTarget?.classList.remove("hidden");
48+
}
49+
50+
appendRow(template, text) {
51+
if (!template || !this.hasContentTarget) return;
52+
const row = template.content.firstElementChild.cloneNode(true);
53+
const content = row.querySelector("[data-slot=bubble-content]");
54+
if (content) content.textContent = text;
55+
this.contentTarget.appendChild(row);
56+
}
57+
58+
disconnect() {
59+
if (this.replyTimer) clearTimeout(this.replyTimer);
60+
}
61+
}

0 commit comments

Comments
 (0)