Message Scroller
A chat transcript's scroll container — opens in the right place, anchors a turn, follows a stream only while the reader is following.
src/components/rahti_ui/message_scroller.rs
Usage
A transcript, a fade, and a control that knows whether it has anywhere to go.
Provider, frame, viewport, transcript, rows and a control — shadcn's six parts in five tags, because the provider renders no element of its own and its five props live on the frame instead.
The frame has no height. It fills its parent, which is shadcn's own instruction and the reason nothing here passes it a class: a caller class becomes a twMerge binding, and a frame with no overflow-hidden until hydration is a page-long transcript for a moment.
Scroll it. The bottom edge dissolves rather than being covered — that is scroll-fade-b, a mask-image driven by a scroll-timeline with no listener behind it, copied out of shadcn's own tailwind.css into globals.css. The round control fades in as soon as there is anything below the fold, and it is inert until then rather than a focus stop that does nothing.
Opening position
Three transcripts, three answers to where a saved thread starts.
The same nine rows, opened three ways, with nothing pressed. `start` is the beginning of the conversation; `end` is the absolute latest message; `last-anchor` is the last row marked as a turn, sitting near the top with its reply below it and a slice of the previous turn above.
`last-anchor` is what a saved thread should reopen at. The absolute bottom drops a reader in without enough context to know where they are; the last thing they asked, with the answer under it, does not.
It is keyed on the anchor, not on who sent the row — and it falls back to `end` when the last turn already fits or there is no anchor at all.
Keeping context visible
scroll_previous_item_peek, at 0, 64 and 140.
How much of the previous turn stays visible above the anchored one. At zero the new turn is flush with the top and the conversation looks like it restarted; at 140 there is enough above it to see what it is answering.
The peek is not a scroll offset — it is a *height*. The transcript renders one spacer after its rows, and the script gives it exactly enough height that the native scroll end puts the anchor that far down. Anchoring the turn and scrolling to the end become one operation, and the spacer shrinks by itself as the reply grows into it.
Controls
One per edge, inert until there is somewhere to go.
Two controls, one per edge. The one pointing up carries `direction="start"`, which flips its arrow, moves it to the top, and makes it slide out upward instead of downward.
Every visual state of a control is a data-[active=…] pair, so the script writes one attribute and the stylesheet does the rest — including two different easings, a fast one for leaving and a slow one for arriving. An inactive control is `inert` with `tabindex="-1"`, so it is not a focus stop that goes nowhere.
Following the live edge
Send a turn, then watch the reply stream into the room below it.
Following: {following ? "yes" : "no"} · at the end: {atEnd ? "yes" : "no"}
Appending a row marked `scroll_anchor` is the reader starting a turn, so the transcript moves whether or not follow-output was engaged: the new row goes to the top with 64px of the previous one above it. The reply then grows into the space below it — and once it fills the viewport the spacer is gone and the reader is at the true live edge.
Scroll up while it streams and the readout flips to `no`. New chunks keep arriving offscreen and nothing moves. Press the round button, or scroll back down, and following is re-engaged.
Loading earlier messages
Older rows arrive above the reader without moving what they are looking at.
Prepended: {loaded} rows
Watch the row under your cursor rather than the scrollbar. The viewport is taller by three messages afterwards and the reader has not moved — the script adds the height difference back to `scrollTop` in the same frame the rows arrive.
This is `preserve_on_prepend`, which is on by default. Turn it off with `no_preserve_on_prepend` on the viewport and the same press throws the reader to the top of the new history.
Commands and state
The three hooks, as one event out and one event in.
anchor: {anchor ? anchor : "none"} · visible: {seen} · scrollable: {edges ? edges : "neither"}
The readout is one `message-scroller-change` listener. `anchor` is `currentAnchorId` — the last turn at or above the reading line, which stays set after that turn scrolls off the top. `visible` is `visibleMessageIds` in document order, and `scrollable` is the `data-scrollable` attribute the root and the viewport both carry.
The four buttons are `scrollToStart`, `scrollToMessage` and `scrollToEnd`: one `message-scroller-scroll` event dispatched at the root, with `detail.to` set to `start`, `end`, or a row's `message_id`. They are outside the transcript, which is the point — a search result or a permalink is too.
The port
What moved, what did not, and why.