MessageScroller

MessageScroller coordinates a variable-height virtual list with the behavior conversation screens usually need: follow the live tail, keep the current anchor while older history is inserted, navigate to an unread row, and expose whether the reader has left the tail.

Install

The application owns the message collection, stable message IDs, unread meaning, row renderer, composer, and empty/error states. The component owns only virtual-list bookkeeping and the optional jump-to-latest affordance. There is one state entity per scroller.

#Import

use std::{rc::Rc, time::Duration};

use gpui_kit::{
    IntoElement as _, ParentElement as _, StyleRefinement, Styled as _,
    prelude::FluentBuilder as _,
};
use gpui_kit::component::{
    ActiveTheme as _,
    button::ButtonVariants as _,
    message_scroller::{MessageScroller, MessageScrollerState},
    Sizable as _,
    v_flex,
};
// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { ChatTurn, MessageScroller } from "message-scroller.slint";
use crate::components::button::{Button, Variant as ButtonVariant};
use crate::components::group_box::{self, GroupBox};
use crate::components::input::Input;
use crate::components::message_scroller::{MessageScroller, Turn, jump_to_end};
use crate::components::theme::{self, ControlSize, Tokens};
use iced::widget::{Id, button, column, container, mouse_area, row, rule, scrollable, svg, text};
use iced::{Alignment as Axis, Color, Element, Length, Padding, Task, Theme};
use std::cell::Cell;
use std::rc::Rc;

#Create state and choose the starting position

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// iced has no separate state entity: the id names the region, so a caller's
// own jump control can pass it to `jump_to_end`, and the turns are the
// application's collection. The starting position is the live tail, because
// the region anchors to its end while rows arrive; there is no persisted
// offset and no arbitrary start index to choose.
let starting = panel(MessageScroller::new(Id::new("starting"), app.turns.clone()));

Create the state beside the application-owned message vector. The constructor receives the entity context because GPUI’s list scroll handler notifies the entity after the list releases its internal borrow:

let scroller = cx.new(|cx| MessageScrollerState::new(messages.len(), cx));
cx.observe(&scroller, |_, _, cx| cx.notify()).detach();
SlintNo Slint version of this example yet.

MessageScrollerState::new(...) starts with tail following enabled. That is the expected starting position for a live conversation. A saved thread or a deep link can choose a row after the initial data has been installed:

let saved_index = messages
    .iter()
    .position(|message| message.id == saved_message_id)
    .unwrap_or(messages.len().saturating_sub(1));

scroller.update(cx, |state, cx| {
    state.reset(messages.len(), cx);
    let _ = state.scroll_to_item(saved_index, cx);
});
description: "Scroll upward, append a row, jump to unread, or prepend history to exercise each behavior.";
HorizontalLayout {
    horizontal-stretch: 1;
    alignment: center;
    Rectangle {
        width: min(Theme.viewport - 48px, 384px);
        height: 540px;
        border-radius: Theme.radius-xl;
        border-width: 1px;
        border-color: Theme.border;
        background: Theme.background;
        clip: true;
        VerticalLayout {
            spacing: 0;
            // Header
            VerticalLayout {
                spacing: 0;
                HorizontalLayout {
                    horizontal-stretch: 1;
                    padding: 20px;
                    spacing: 8px;
                    VerticalLayout {
                        horizontal-stretch: 1;
                        spacing: 2px;
                        Text {
                            text: "New Chat";
                            color: Theme.foreground;
                            font-size: Theme.font-size-sm;
                            font-weight: 600;
                        }
                        Text {
                            text: "How can I help you today?";
                            color: Theme.muted-foreground;
                            font-size: Theme.font-size-xs;
                        }
                    }
                    Button {
                        icon: "refresh-cw";
                        outline: true;
                        size: ControlSize.small;
                    }
                }
                Rectangle {
                    width: 100%;
                    height: 1px;
                    background: Theme.border;
                }
            }
            // The list sits in its own band. The scroller states
            // `height: 100%`, so the band must already be the leftover
            // height or the composer below is squeezed out.
            Rectangle {
                vertical-stretch: 1;
                min-height: 0px;
                background: transparent;
                MessageScroller {
                    unread-index: root.unread;
                    scrolled-up: root.jumped-up;
                    messages: [
                        { role: MessageRole.user, body: "You won't. Scrolling up releases tail following, so the reading position is preserved while new rows keep arriving below." },
                        { role: MessageRole.user, body: "A jump-to-latest button appears once the reader leaves the tail; one click returns to the newest row and resumes following." },
                        { role: MessageRole.user, body: "And loading older history when they reach the top?" },
                        { role: MessageRole.user, body: "The application prepends those rows and the scroller keeps the visible anchor steady." },
                        { role: MessageRole.assistant, body: "That is the anchor rule: the row the reader is looking at stays where it is." },
                        { role: MessageRole.user, body: "So a burst of new rows never yanks the view." },
                        { role: MessageRole.assistant, body: "Correct. Following resumes only when the reader returns to the tail." },
                    ];
                }
            }
            // Composer
            VerticalLayout {
                padding-left: 20px;
                padding-right: 20px;
                padding-bottom: 20px;
                Rectangle {
                    border-radius: Theme.radius-lg;
                    background: Theme.muted;
                    VerticalLayout {
                        padding: 8px;
                        spacing: 6px;
                        Input {
                            appearance: false;
                            placeholder: "Write a message…";
                        }
                        HorizontalLayout {
                            horizontal-stretch: 1;
                            alignment: end;
                            // Clipped to a circle, the way GPUI rounds its
                            // send button to `radius_full()`.
                            Rectangle {
                                width: 28px;
                                height: 28px;
                                border-radius: 14px;
                                clip: true;
                                Button {
                                    width: 100%;
                                    height: 100%;
                                    icon: "arrow-up";
                                    variant: ButtonVariant.primary;
                                    size: ControlSize.small;
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

There is no starting_position field or persisted scroll-offset API. Keep the application’s saved message ID or index, then resolve it to the current index after records are loaded. reset(...) replaces the known row count and re-engages tail following; call scroll_to_item(...) after it when the product needs a different initial row.

#Render rows and an empty state

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// A row is a bubble, chosen by role: a sent turn is the muted surface at the
// trailing edge and a received turn is ghost text at the leading edge, so the
// component draws the rows itself from the turns. An empty list draws
// nothing, so the empty state stays with the application beside it.
let rows = panel(MessageScroller::new(Id::new("rows"), app.turns.clone()));
let empty = container(
    column![
        line("No messages yet", theme::FONT_SIZE_SM, |tokens| {
            tokens.foreground
        }),
        line(
            "Ask a question to start the thread.",
            theme::FONT_SIZE_XS,
            |tokens| tokens.muted_foreground,
        ),
    ]
    .spacing(theme::SPACING_XS),
)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill);
let render = row![rows, empty].spacing(theme::SPACING_MD);

Pass an indexed renderer. GPUI virtualizes the rows, so the closure is called for the rows needed by the current viewport and overdraw region:

let messages = Rc::new(messages.clone());

MessageScroller::new(
    "conversation",
    scroller.clone(),
    move |index, _window, _cx| {
        let Some(message) = messages.get(index) else {
            return gpui_kit::div().into_any_element();
        };

        gpui_kit::div()
            .id(("message-row", message.id))
            .min_w_0()
            .child(render_message(message))
            .into_any_element()
    },
)
.w_full()
.h_96()
SlintNo Slint version of this example yet.

The row ID in this example belongs to the application. MessageScroller does not retain an index-to-ID map; the ID lets an application-owned row keep its own element-local state when data changes.

An empty list is valid (MessageScrollerState::new(0, cx)), but the scroller does not invent an empty placeholder. Render the empty, loading, error, or permission-denied state in the surrounding view and mount the scroller once there are rows:

if messages.is_empty() {
    empty_conversation_view.into_any_element()
} else {
    MessageScroller::new("conversation", scroller.clone(), render_message)
        .into_any_element()
}
Rectangle {
    width: 100%;
    height: 200px;
    border-width: 1px;
    border-color: Theme.border;
    border-radius: Theme.radius-lg;
    clip: true;
    background: Theme.background;
    VerticalLayout {
        alignment: center;
        // `alignment` is the vertical axis here; without this the icon
        // and the copy stay left-aligned inside the centred stack.
        cross-axis-alignment: center;
        spacing: 10px;
        Rectangle {
            width: 44px;
            height: 44px;
            border-radius: 22px;
            background: Theme.muted;
            Icon {
                x: (parent.width - self.width) / 2;
                y: (parent.height - self.height) / 2;
                name: "message-circle";
                size: 20px;
                color: Theme.muted-foreground;
            }
        }
        Text {
            text: "No messages yet";
            color: Theme.foreground;
            font-size: Theme.font-size-sm;
            font-weight: 600;
            horizontal-alignment: center;
        }
        Text {
            text: "Ask a question to start the thread.";
            color: Theme.muted-foreground;
            font-size: Theme.font-size-xs;
            horizontal-alignment: center;
        }
    }
}

Keep the empty state separate from the scroll region so it can provide a meaningful action such as “Start a new conversation” without pretending there is a message to scroll to.

#Append, streaming, and follow-tail behavior

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// Appending while the reader follows the tail keeps the newest row in view;
// appending after they scroll up preserves their place and raises the fade
// and the control. iced owns the offset, so the component reports the
// crossing and the caller feeds it back through `scrolled_up`.
let tail = column![
    panel(
        MessageScroller::new(Id::new(CONVERSATION), app.turns.clone())
            .scrolled_up(scrolled_up)
            .unread_index(unread_index)
            .on_scroll({
                let flag = app.scrolled_up.clone();
                move |scrolled_up| {
                    flag.set(scrolled_up);
                    Message::Scrolled(scrolled_up)
                }
            })
            .on_jump(Message::JumpTo(CONVERSATION)),
    ),
    row![
        chip(
            format!("received turn ({})", app.appended),
            Message::Received
        ),
        chip("sent turn", Message::Sent),
        line(
            "the newest turn stays in view only while the reader is at the tail",
            theme::FONT_SIZE_XS,
            |tokens| tokens.muted_foreground,
        ),
    ]
    .spacing(theme::SPACING_SM)
    .align_y(Axis::Center),
]
.spacing(theme::SPACING_SM);

Update application data and virtual-list count together. Appending while the reader follows the tail keeps the latest row visible. Appending after the reader scrolls up preserves the reader’s position and makes the built-in jump button available:

messages.push(new_message);
scroller.update(cx, |state, cx| {
    let _ = state.append(1, cx);
});
cx.notify();
SlintNo Slint version of this example yet.

Streaming token growth changes a row’s height without changing the item count. Update the message body, then remeasure that row:

messages[index].body.push_str(next_token);
scroller.update(cx, |state, cx| {
    let _ = state.remeasure_items(index..index + 1, cx);
});
cx.notify();
SlintNo Slint version of this example yet.

remeasure_items(...) preserves an item anchor while recalculating the selected rows. Use remeasure(...) after a global width, typography, or theme change that can affect many row heights. When streaming creates a new message rather than growing an existing one, call append(1, cx) first; remeasure the row again if its first render and later content have different heights.

The state readers make the follow-tail decision visible to the surrounding view:

let following_tail = scroller.read(cx).is_following_tail();
let show_new_messages = scroller.read(cx).is_scrolled_up();
SlintNo Slint version of this example yet.

is_following_tail() is true when the list follows appended content. is_scrolled_up() is true when there is scrollable content below the current viewport and the reader is away from the end. The component does not decide whether to show a toast, unread count, or “new messages” copy; use these readers to drive an application-owned indicator.

Resume following and move to the latest row explicitly:

scroller.update(cx, |state, cx| state.scroll_to_end(cx));
description: "Append one assistant response, grow its text progressively, and remeasure only that row.";
HorizontalLayout {
    horizontal-stretch: 1;
    alignment: center;
    Rectangle {
        width: min(Theme.viewport - 48px, 384px);
        height: 300px;
        border-radius: Theme.radius-xl;
        border-width: 1px;
        border-color: Theme.border;
        background: Theme.background;
        clip: true;
        Rectangle {
            width: 100%;
            height: 100%;
            background: transparent;
            MessageScroller {
                messages: [
                    { role: MessageRole.assistant, body: "Conversation message 4" },
                    { role: MessageRole.assistant, body: "Message 5 wraps across more than one line to keep virtual-row measurement realistic." },
                    { role: MessageRole.assistant, body: "Conversation message 6" },
                    { role: MessageRole.user, body: "How does streaming preserve my position?" },
                ];
            }
        }
    }
}

Normal scrolling to the end also allows GPUI’s list to resume tail following.

#Prepend earlier history

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// Older records are the application's to load and splice in at the front.
// The region keeps its anchor, so the turns the reader is looking at do not
// move when earlier ones arrive above them.
let history = panel(MessageScroller::new(Id::new("history"), app.turns.clone()));
let prepend = column![
    history,
    chip(
        format!("load earlier ({})", app.prepended),
        Message::Prepend,
    ),
    line(
        "a prepend inserts above the anchor, so the visible turn stays put",
        theme::FONT_SIZE_XS,
        |tokens| tokens.muted_foreground,
    ),
]
.spacing(theme::SPACING_SM);

Insert older records at the front and tell the state the number of inserted rows. prepend(...) uses GPUI list splicing to preserve the visible item anchor:

let earlier_messages = load_earlier_messages();
let count = earlier_messages.len();
messages.splice(0..0, earlier_messages);

scroller.update(cx, |state, cx| {
    let _ = state.prepend(count, cx);
});
cx.notify();
Rectangle {
    width: 100%;
    height: 200px;
    border-width: 1px;
    border-color: Theme.border;
    border-radius: Theme.radius-lg;
    clip: true;
    MessageScroller {
        messages: [
            { role: MessageRole.assistant, body: "… 48 earlier messages loaded above." },
            { role: MessageRole.user, body: "Where were we?" },
            { role: MessageRole.assistant, body: "Picking a framework. Both ports render the same gallery." },
        ];
    }
}

Use splice(old_range, count, cx) for replacements or deletions elsewhere in the collection. The range is half-open and must stay within the current item count; invalid ranges return false and leave the state unchanged.

For a “Load earlier” control, keep the loading state in the application, fetch the records, splice the vector, then call prepend. Do not call reset for ordinary history pagination because reset intentionally returns to tail following and loses the incremental anchor semantics.

#Unread and arbitrary navigation

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The unread rule is the index of the first turn the reader has not seen — a
// value the application owns, because it is the one that knows an unread,
// searched or bookmarked identity. iced 0.14 exposes only `jump_to_end`, so
// arbitrary scroll-to-index has no counterpart here; the rule marks the
// boundary and the jump returns to the tail.
let navigation = panel(
    MessageScroller::new(Id::new("navigation"), app.turns.clone()).unread_index(unread_index),
);
let navigating = column![
    navigation,
    chip(
        if app.unread {
            "unread rule on"
        } else {
            "unread rule off"
        },
        Message::ToggleUnread,
    ),
]
.spacing(theme::SPACING_SM);

Unread identity belongs to the application. Resolve a stable message ID to the current vector index, then use scroll_to_item(...):

if let Some(index) = messages.iter().position(|message| message.id == unread_id) {
    scroller.update(cx, |state, cx| {
        let _ = state.scroll_to_item(index, cx);
    });
}
SlintNo Slint version of this example yet.

scroll_to_item(...) returns false for an out-of-range index. It is the single navigation primitive: an unread boundary, a search result, a bookmarked message, a reply target, and a deep link all resolve to an index in the application first.

The current API does not expose turn anchors, peek previews, visible IDs, or stable-ID navigation. Map domain IDs to the current index in the application; keep an index map if lookup cost matters. The scroller does not know whether a row is a turn, a reply, an unread boundary, or a search result.

#Dynamic row heights and structural updates

Rows may contain multiline text, attachments, streamed content, or an application-owned composer and can therefore have different heights. The underlying GPUI list measures rendered rows. Keep the renderer’s height-affecting data in the owning view and notify the state after a mutation:

ChangeState operation
Add rows at the tailappend(count, cx)
Add rows at the frontprepend(count, cx)
Replace/delete a rangesplice(range, count, cx)
Token growth in known rowsremeasure_items(range, cx)
Global width/font/theme changeremeasure(cx)
Replace the whole conversationreset(item_count, cx)

Do not mutate the vector length without the matching state operation. The renderer receives an index, so data and virtual-list count must remain aligned for the same render pass.

#Jump-to-latest controls

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The control is drawn while `scrolled_up` is true and reports the press; the
// caller runs `jump_to_end` on the region's id, because iced owns the offset.
// Asking for `scrolled_up(true)` here is what draws the control without a
// scroll, the way the Slint example sets `scrolled-up`.
let jump = panel(
    MessageScroller::new(Id::new("jump"), app.turns.clone())
        .scrolled_up(true)
        .jump_button(app.jump_button)
        .on_jump(Message::JumpTo("jump")),
);
let jumping = column![
    jump,
    chip(
        if app.jump_button {
            "built-in control on"
        } else {
            "built-in control off"
        },
        Message::ToggleJumpButton,
    ),
]
.spacing(theme::SPACING_SM);

The built-in jump button is enabled by default and appears when is_scrolled_up() becomes true. It is a configured Button with a secondary variant, icon-button sizing, full radius, arrow-down icon, theme border/background, and a localized tooltip label. It keeps the scroll action owned by the state:

MessageScroller::new("conversation", scroller.clone(), render_message)
    .with_jump_button_label("Jump to newest")
    .with_jump_button_transition(Duration::from_millis(250))
SlintNo Slint version of this example yet.

Use Duration::ZERO to disable the enter/leave transition. Reduced-motion preferences use the final state immediately regardless of the configured duration.

Refine its style after the built-in defaults:

MessageScroller::new("conversation", scroller.clone(), render_message)
    .with_jump_button_style(
        StyleRefinement::default()
            .bg(cx.theme().primary)
            .border_color(cx.theme().primary)
            .text_color(cx.theme().primary_foreground),
    )
SlintNo Slint version of this example yet.

Use the renderer callback when the application needs a different Button variant, size, icon, or instance style. The callback receives the fully configured button and must return a Button; the built-in scroll action stays attached:

MessageScroller::new("conversation", scroller.clone(), render_message)
    .with_jump_button_renderer(|button| button.outline().small().label("Latest"))
SlintNo Slint version of this example yet.

During its leave transition the built-in button is rendered disabled while its opacity reaches zero. A renderer should preserve that state rather than force a disabled button to be active. The button’s accessible name comes from its .label(...) value; with_jump_button_label(...) supplies the tooltip label only. Set a visible label in with_jump_button_renderer(...) when the jump action needs a named accessible control. The current public renderer callback can change Button styling and content, but it does not expose a separate accessibility-label builder.

Disable the built-in affordance when the surrounding view provides its own:

MessageScroller::new("conversation", scroller.clone(), render_message)
    .jump_button(false)
Rectangle {
    width: 100%;
    height: 240px;
    border-width: 1px;
    border-color: Theme.border;
    border-radius: Theme.radius-lg;
    clip: true;
    MessageScroller {
        scrolled-up: true;
        messages: [
            { role: MessageRole.user, body: "Scroll up to read the history." },
            { role: MessageRole.assistant, body: "The fade marks the bottom edge while the newest turn is out of view." },
            { role: MessageRole.user, body: "What does the round control do?" },
            { role: MessageRole.assistant, body: "It returns the viewport to the end of the conversation." },
        ];
    }
}

Compose an application-owned button from is_scrolled_up() and scroll_to_end(...) when its placement, text, or accessibility contract needs to be product-specific.

#Scrollbar and style slots

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The builder carries the style slots the page documents: the list inset, the
// muted bubble's width cap and the scrollbar. A host-drawn control is
// `jump_button(false)` plus an application button that runs `jump_to_end` on
// the same id, which is why this frame keeps `scrolled_up` true.
let slots = panel(
    MessageScroller::new(Id::new("slots"), app.turns.clone())
        .scrolled_up(true)
        .jump_button(false)
        .scrollbar(false)
        .padding(theme::SPACING_LG)
        .max_bubble_width(300.0),
);
let latest = Button::new("Latest")
    .variant(ButtonVariant::Secondary)
    .size(ControlSize::Small)
    .on_press(Message::JumpTo("slots"));
let styled = column![slots, latest].spacing(theme::SPACING_SM);

The root implements Styled, and the internal regions have separate style refinements:

MessageScroller::new("conversation", scroller.clone(), render_message)
    .p_2()
    .bg(cx.theme().group_box)
    .with_content_style(StyleRefinement::default().bg(cx.theme().background))
    .with_list_style(StyleRefinement::default().p_4())
    .with_row_style(StyleRefinement::default().pb_6())
    .scrollbar(false)
Rectangle {
    width: 100%;
    height: 200px;
    border-width: 1px;
    border-color: Theme.border;
    border-radius: Theme.radius-lg;
    clip: true;
    MessageScroller {
        jump-button: false;
        messages: [
            { role: MessageRole.user, body: "Give me the short version." },
            { role: MessageRole.assistant, body: "Copy a component, import it, and it runs." },
            { role: MessageRole.user, body: "That is short." },
            { role: MessageRole.assistant, body: "The long version is on the Dock page." },
        ];
    }
    HorizontalLayout {
        x: 0px;
        // The row states its own height: a layout left unconstrained
        // fills its parent, and `self.height` would then be the whole
        // frame, putting the pill at the top.
        y: parent.height - self.height - 12px;
        width: parent.width;
        height: 28px;
        padding-right: 12px;
        // `end` keeps the pill at its own size; a bare rectangle with no
        // width stretches to the frame and pushes its label off-centre.
        alignment: end;
        Rectangle {
            height: 28px;
            border-radius: 14px;
            border-width: 1px;
            border-color: Theme.border;
            background: Theme.background;
            HorizontalLayout {
                padding-left: 12px;
                padding-right: 12px;
                spacing: 6px;
                // The cross axis centres the glyph and the label on the
                // pill's own height.
                cross-axis-alignment: center;
                Icon {
                    name: "arrow-down";
                    size: 13px;
                    color: Theme.foreground;
                }
                Text {
                    text: "Latest";
                    color: Theme.foreground;
                    font-size: Theme.font-size-xs;
                    vertical-alignment: center;
                }
            }
        }
    }
}

The boundaries are:

  • Root Styled methods refine the full-width element that owns the viewport.
  • with_content_style(...) refines the viewport containing the list and optional vertical scrollbar.
  • with_list_style(...) refines the GPUI virtual list after its default px_3() / py_2() padding. GPUI lists offset rows only vertically, so the horizontal padding component — the default and any refinement — is carried by every row wrapper.
  • with_row_style(...) refines the full-width wrapper around each rendered row; the default wrapper includes pb_8() between rows, like a CSS gap. The list’s own bottom padding owns the gap between the last row and whatever sits below the transcript.
  • scrollbar(false) hides the built-in vertical scrollbar; it does not disable scrolling or remove keyboard/wheel interaction.
  • with_bottom_fade(color) fades the transcript’s bottom edge into the given color, so a partially visible row melts into the surrounding surface instead of clipping mid-line. It shows only while the reader is away from the live edge — at the bottom nothing is clipped. Pass the color of the surface behind the scroller; the fade is off by default.

Use theme roles such as group_box, background, border, and foreground for custom surfaces. Keep content padding in the surrounding conversation shell when it belongs to the shell’s header/composer relationship; use list or row style when it belongs to every transcript row.

#Virtualization, accessibility, and application boundaries

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The first example is the one the reference leads with: the whole
// conversation shell. The scroller owns the transcript band and nothing
// else. The shell around it — the header with its circular restart, and the
// composer with its circular send — is the application's, which is the
// boundary this heading documents when it places the composer outside the
// list. The rows the scroller draws are unchanged: a sent turn is the muted
// bubble at the trailing edge and a received turn is the ghost variant,
// which is plain text on the surface with no fill, border, radius or
// padding.
let restart = Button::new("")
    .outline()
    .icon(icon(
        REFRESH_CW,
        ControlSize::Medium.icon_size(),
        |tokens| tokens.foreground,
    ))
    .rounded(CIRCLE_RADIUS)
    .on_press(Message::Restart);

let header = container(
    row![
        column![
            text("New Chat").size(theme::FONT_SIZE_SM).font(iced::Font {
                weight: iced::font::Weight::Semibold,
                ..iced::Font::DEFAULT
            }),
            line("How can I help you today?", theme::FONT_SIZE_XS, |tokens| {
                tokens.muted_foreground
            }),
        ]
        .spacing(theme::SPACING_XS)
        .width(Length::Fill),
        restart,
    ]
    .spacing(theme::SPACING_SM)
    .align_y(Axis::Center),
)
.width(Length::Fill)
.padding(SHELL_PADDING);

let send = Button::new("")
    .variant(ButtonVariant::Primary)
    .icon(icon(ARROW_UP, ControlSize::Medium.icon_size(), |tokens| {
        tokens.primary_foreground
    }))
    .rounded(CIRCLE_RADIUS)
    .on_press(Message::Send);

// The composer is a borderless field on the muted surface, with the send
// control on the trailing edge — GPUI's `Input.appearance(false)` inside a
// `rounded_xl()` `bg(muted)` column, and the Slint example's composer.
let composer = container(
    column![
        Input::new("composer")
            .value(app.composer.clone())
            .placeholder("Write a message…")
            .appearance(false)
            .on_input(Message::ComposerChanged)
            .on_submit(Message::Send),
        container(send).width(Length::Fill).align_x(Axis::End),
    ]
    .spacing(theme::SPACING_XS),
)
.width(Length::Fill)
.padding(theme::SPACING_SM)
.style(|theme: &Theme| {
    let tokens = Tokens::of(theme);
    container::Style {
        background: Some(tokens.muted.into()),
        text_color: Some(tokens.foreground),
        border: iced::border::rounded(theme::RADIUS_XL),
        ..container::Style::default()
    }
});

let shell = container(
    column![
        header,
        hairline(),
        MessageScroller::new(Id::new("shell"), app.turns.clone())
            .unread_index(unread_index)
            .scrolled_up(scrolled_up)
            .on_scroll({
                let flag = app.scrolled_up.clone();
                move |scrolled_up| {
                    flag.set(scrolled_up);
                    Message::Scrolled(scrolled_up)
                }
            })
            .on_jump(Message::JumpTo("shell")),
        container(composer).width(Length::Fill).padding(Padding {
            top: 0.0,
            right: SHELL_PADDING,
            bottom: SHELL_PADDING,
            left: SHELL_PADDING,
        }),
    ]
    // The column owns the frame's height so the transcript band, a `Fill`
    // child, receives the space the header and composer leave.
    .height(Length::Fill),
)
.width(SHELL_WIDTH)
.height(SHELL_HEIGHT)
.clip(true)
.style(|theme: &Theme| {
    let tokens = Tokens::of(theme);
    container::Style {
        background: Some(tokens.background.into()),
        text_color: Some(tokens.foreground),
        border: iced::border::rounded(theme::RADIUS_XL)
            .color(tokens.border)
            .width(1.0),
        ..container::Style::default()
    }
});

let boundaries = column![
    row![
        Button::new("Append").on_press(Message::Received),
        Button::new("Prepend history").on_press(Message::Prepend),
    ]
    .spacing(theme::SPACING_SM),
    line(
        "the scroller owns the transcript band; the header, the composer and \
         their controls are the application's",
        theme::FONT_SIZE_XS,
        |tokens| tokens.muted_foreground,
    ),
    shell,
]
.spacing(theme::SPACING_MD);

MessageScroller delegates viewport layout, variable-height measurement, scroll anchoring, and overdraw to GPUI’s ListState. Only visible rows and the configured overdraw region need rendering. Keep row closures deterministic and avoid doing network work or mutating the message collection during rendering.

For keyboard and screen-reader behavior:

  • Keep the scroller inside a layout with a real height and min_h_0() so the scroll region can receive wheel and keyboard navigation.
  • The transcript viewport announces itself as a log region (Role::Log), so assistive technology can treat appended rows as live additions.- Wheel scrolling over the transcript is contained: while the list can move, the event never scrolls an ancestor scroller; at the top or bottom edge it chains to the ancestor, matching platform scroll containers.
  • Give rows meaningful text and stable application IDs; an index by itself is not a user-facing label.
  • Give the jump control an explicit visible label when it must be exposed as a named accessible action. Its tooltip is supplemental.
  • Place “Load earlier”, retry, composer, and unread controls outside the list in semantic Button or Link controls.
  • Keep empty, loading, error, and permission states readable without relying on animation or scrollbar position.

The component intentionally has no React-style Provider, Viewport, Content, or Item exports. GPUI’s list already supplies those layers; an indexed renderer is the item boundary. It also has no turn-anchor, peek, visible-range, or stable-ID API. Those concepts vary by product and belong in the application model around this component.

#API reference

#MessageScrollerState

MethodDefault/returnPurpose
new(item_count, cx)tail following enabledCreate state for the current row count.
item_count()current countRead the virtual-list row count.
is_scrolled_up()false until away from tailReport whether a jump/new-content affordance is useful.
is_following_tail()true initiallyReport whether appended rows are followed.
reset(item_count, cx)re-engages tailReplace the row count and reset list state.
splice(range, count, cx)true if validReplace a half-open range while preserving list bookkeeping.
append(count, cx)splice at tailAdd rows at the end.
prepend(count, cx)splice at index 0Add earlier rows while preserving the current anchor.
remeasure(cx)—Remeasure all rows after global layout changes.
remeasure_items(range, cx)true if validRemeasure selected dynamic rows.
scroll_to_item(index, cx)false if out of rangeNavigate to an arbitrary row index.
scroll_to_end(cx)tail following enabledMove to the latest row and resume following.

#MessageScroller

MethodDefaultPurpose
new(id, state, renderer)scrollbar and jump button enabledCreate a virtualized scroller.
scrollbar(bool)trueShow or hide the internal scrollbar.
jump_button(bool)trueShow or hide the built-in jump control.
with_jump_button_label(label)Jump to latestSet the jump tooltip/localized label.
with_content_style(style)empty refinementStyle the viewport and scrollbar region.
with_list_style(style)list px_3() / py_2()Style the virtual list.
with_row_style(style)row pb_8()Style every rendered row wrapper.
with_jump_button_style(style)themed secondary buttonRefine the built-in button.
with_jump_button_renderer(callback)default ButtonAdjust the configured button while keeping its action.
with_jump_button_transition(duration)200 msSet enter/leave duration; reduced motion skips it.
with_bottom_fade(color)offFade the bottom edge into the surrounding surface color.
Styled methodsfull-size, clipped rootStyle the outer scroller element.

#ChatTurn

NameKindTypeDefault
rolefieldMessageRole
bodyfieldstring

#MessageScroller

A conversation that stays pinned to the newest turn. Mirrors GPUI Component's `MessageScroller`. Rows are bubbles, not `Message`: GPUI's story renders "no avatars or author names, sent rows on a muted surface, received rows as ghost text", and a `Bubble` is what draws that. Slint cannot read a scroll position, so the application owns whether the view is at the newest turn: it sets `scrolled-up`, and this component shows the bottom fade and the jump control from that. GPUI derives the same two values from the list state it owns.

NameKindTypeDefaultDescription
messagesin-out[ChatTurn]
unread-indexinint-1Draws the "Unread" rule before this row. -1 draws none.
scrolled-upinboolfalseTrue while the newest turn is out of view.
jump-buttoninbooltrue
list-paddinginlength20pxThe inset around the list. GPUI's story passes `p_5` here.
jump-requestedcallback()Called when the person asks to return to the newest turn.