# MessageScroller

The transcript viewport — a virtualized list of turns, anchored to the newest one, that reports the band the reader has in front of them.

```slint
import { MessageItem, MessageScroller } from "@glint/components/message-scroller.slint";
import { MessageRole } from "@glint/components/message-bubble.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component Demo inherits Window {
    width: 560px;
    height: 360px;
    background: Tokens.color-background;

    property <[MessageItem]> turns: [
        { role: MessageRole.user,  id: "t1", text: "Why is the parser dropping spans?" },
        { role: MessageRole.agent, id: "t2", text: "`fold_expr` rebuilds the node and never carries the span over." },
        { role: MessageRole.user,  id: "t3", text: "Does that affect the diagnostics too?" },
        { role: MessageRole.agent, id: "t4", text: "Every diagnostic under a folded expression points at the file rather than at the line." },
        { role: MessageRole.user,  id: "t5", text: "Fix it and add a test." },
        { role: MessageRole.agent, id: "t6", text: "Done. The test folds a nested call and asserts the span survives." },
        { role: MessageRole.user,  id: "t7", text: "Run the whole suite." },
        { role: MessageRole.agent, id: "t8", text: "212 passed, 0 failed." },
    ];

    VerticalLayout {
        padding: 20px;

        Rectangle {
            border-width: 1px;
            border-color: Tokens.color-border-hairline;
            border-radius: Tokens.radius-lg;
            clip: true;

            MessageScroller {
                messages: root.turns;
            }
        }
    }
}
```

## Usage

```slint
import { MessageItem, MessageScroller } from "@glint/components/message-scroller.slint";

export component AppWindow inherits Window {
    // The transcript, oldest turn first. The model is yours; what the
    // scroller follows is its growth.
    in property <[MessageItem]> turns;

    VerticalLayout {
        transcript := MessageScroller {
            messages: root.turns;
            // The band the reader has in front of them, as positions in
            // `messages` — mark them read, fire receipts, follow along in a
            // side index.
            changed last-visible-turn => {
                debug("read up to turn ", transcript.last-visible-turn);
            }
        }
    }
}
```

**A long conversation is a row model, not a layout.** The scroller renders one [MessageBubble](/docs/components/message-bubble) per turn through Glint’s virtualized list, so only the turns in view are instantiated and a ten-thousand-turn transcript costs what a ten-turn one does.

A virtualized row cannot own a slot — Slint gives a component one `@children` and the scroller spends it on nothing — so a turn is `role` and `text` and no more, the same trade [DataTable](/docs/components/data-table) makes for its rows. Compose `MessageBubble` by hand inside a [ScrollArea](/docs/components/scroll-area) when a turn needs a code block or a layout of its own.

`MessageItem` is a data type rather than a component:

| Field    | What it is                                                     |
| -------- | -------------------------------------------------------------- |
| `role`   | Who authored the turn — it tints the bubble and picks its side |
| `text`   | What the turn says, and what the bubble wraps                  |
| `id`     | A stable name for the turn, reported back as `anchor-id`       |
| `anchor` | Marks the turn the viewport should land on when it arrives     |

**Sought by position, reported by name.** The commands take a position — `scroll-to-turn(index)` — and what comes back is named: `anchor-id` is the turn at the top of the viewport. The asymmetry is not a preference (ADR-0033). Slint’s expression language has no loop, so an array cannot be searched, and a virtualized list has no rows to walk — so resolving your own id to a position is yours, and you are the one holding the model. Reporting is the reverse: the turns on screen are the turns that exist, so each can answer for itself.

That is why `id` earns its place on `MessageItem`. A position is not a stable name for a turn once history lands above it, and read receipts, a side index and “jump back to where I was” all need one that is.

## Examples

### The pin

The viewport is anchored to the newest turn. While `pinned` holds, every turn appended to `messages` scrolls the view down to keep the newest one in sight; scrolling back through the history releases it, and the jump-to-latest control — or scrolling back down — takes it again. The control exists only while it has something to do, so it leaves the tab order and the accessibility tree the moment the viewport is pinned again.

```slint
import { Button, ButtonVariant } from "@glint/components/button.slint";
import { MessageItem, MessageScroller } from "@glint/components/message-scroller.slint";
import { MessageRole } from "@glint/components/message-bubble.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component Demo inherits Window {
    width: 560px;
    height: 420px;
    background: Tokens.color-background;

    in-out property <bool> replied: false;

    // Two prepared transcripts rather than one that grows: Slint arrays do
    // not grow and one cannot be built from another, so a preview appends a
    // turn by handing the scroller the longer of the two.
    property <[MessageItem]> asked: [
        { role: MessageRole.user,  id: "t1", text: "Start the release checklist." },
        { role: MessageRole.agent, id: "t2", text: "Checking the tags first." },
        { role: MessageRole.user,  id: "t3", text: "Anything on main since v0.1.0?" },
        { role: MessageRole.agent, id: "t4", text: "Eleven commits, none of them touching the public API." },
        { role: MessageRole.user,  id: "t5", text: "Good. Draft the notes." },
    ];
    property <[MessageItem]> answered: [
        { role: MessageRole.user,  id: "t1", text: "Start the release checklist." },
        { role: MessageRole.agent, id: "t2", text: "Checking the tags first." },
        { role: MessageRole.user,  id: "t3", text: "Anything on main since v0.1.0?" },
        { role: MessageRole.agent, id: "t4", text: "Eleven commits, none of them touching the public API." },
        { role: MessageRole.user,  id: "t5", text: "Good. Draft the notes." },
        { role: MessageRole.agent, id: "t6", text: "Drafted. Two sections: the parser fix and the docs site." },
    ];

    VerticalLayout {
        padding: 20px;
        spacing: 12px;

        Rectangle {
            // A fixed viewport: the transcript's height must not depend on
            // the line under it that reports what the transcript is doing.
            height: 240px;
            border-width: 1px;
            border-color: Tokens.color-border-hairline;
            border-radius: Tokens.radius-lg;
            clip: true;

            transcript := MessageScroller {
                messages: root.replied ? root.answered : root.asked;
            }
        }

        HorizontalLayout {
            spacing: 12px;
            alignment: start;

            Button {
                variant: ButtonVariant.outline;
                text: root.replied ? "Take the reply back" : "Append a reply";
                clicked => { root.replied = !root.replied; }
            }

            Text {
                text: transcript.pinned
                    ? "Pinned — an arriving turn scrolls the view down."
                    : "Released — the view stays where you left it.";
                color: Tokens.color-muted-foreground;
                font-size: Tokens.typography-body-sm-size;
                vertical-alignment: center;
            }
        }
    }
}
```

### Seeking to a turn

`jump-to-latest()`, `scroll-to-start()` and `scroll-to-turn(index)` are the three things the reader does not drive. A seek is not the live edge, so it releases the pin — which is what puts the jump-to-latest control on screen, the reader’s way back — and the reader scrolling cancels whatever you had in flight, because taking the view over is a clearer statement of intent than any command.

A seek is a walk rather than a jump: rows are bubbles, so they are not a fixed height and there is no arithmetic from an index to an offset. The scroller estimates off the turn it has measured and then steers by whichever turn reports itself standing at the top of the viewport.

```slint
import { Button, ButtonVariant } from "@glint/components/button.slint";
import { MessageItem, MessageScroller } from "@glint/components/message-scroller.slint";
import { MessageRole } from "@glint/components/message-bubble.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component Demo inherits Window {
    width: 560px;
    height: 440px;
    background: Tokens.color-background;

    property <[MessageItem]> turns: [
        { role: MessageRole.user,  id: "t1", text: "Turn 1 — the beginning of the conversation." },
        { role: MessageRole.agent, id: "t2", text: "Turn 2." },
        { role: MessageRole.user,  id: "t3", text: "Turn 3." },
        { role: MessageRole.agent, id: "t4", text: "Turn 4 — the one worth quoting." },
        { role: MessageRole.user,  id: "t5", text: "Turn 5." },
        { role: MessageRole.agent, id: "t6", text: "Turn 6." },
        { role: MessageRole.user,  id: "t7", text: "Turn 7." },
        { role: MessageRole.agent, id: "t8", text: "Turn 8." },
        { role: MessageRole.user,  id: "t9", text: "Turn 9." },
        { role: MessageRole.agent, id: "t10", text: "Turn 10 — the live edge." },
    ];

    VerticalLayout {
        padding: 20px;
        spacing: 12px;

        Rectangle {
            // A fixed viewport: the transcript's height must not depend on
            // the line under it that reports what the transcript is doing.
            height: 240px;
            border-width: 1px;
            border-color: Tokens.color-border-hairline;
            border-radius: Tokens.radius-lg;
            clip: true;

            transcript := MessageScroller {
                messages: root.turns;
            }
        }

        HorizontalLayout {
            spacing: 8px;
            alignment: start;

            Button {
                variant: ButtonVariant.outline;
                text: "To the start";
                clicked => { transcript.scroll-to-start(); }
            }
            Button {
                variant: ButtonVariant.outline;
                text: "To the quoted turn";
                // The consumer maps its own id to a position; it built the array.
                clicked => { transcript.scroll-to-turn(3); }
            }
            Button {
                variant: ButtonVariant.outline;
                text: "To the latest";
                clicked => { transcript.jump-to-latest(); }
            }
        }

        Text {
            text: "Anchor: " + transcript.anchor-id
                + " · turns " + transcript.first-visible-turn
                + "–" + transcript.last-visible-turn + " in view";
            color: Tokens.color-muted-foreground;
            font-size: Tokens.typography-body-sm-size;
        }
    }
}
```

### The band of visible turns, and the anchor

`first-visible-turn` and `last-visible-turn` are the turns the reader has in front of them, as positions in `messages`; `anchor-id` is the one at the top edge, named rather than numbered so it stays the same turn while history lands above it. Scroll the transcript below and watch all three move.

A turn marked `anchor: true` is not merely scrolled into sight when it arrives: it goes to the top of the viewport under `anchor-peek`, so the reader starts reading at the start of the turn and can still see there was something before it. The last turn carries a viewport’s worth of headroom when it is an anchor, because a transcript cannot be scrolled past its own end.

```slint
import { MessageItem, MessageScroller } from "@glint/components/message-scroller.slint";
import { MessageRole } from "@glint/components/message-bubble.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component Demo inherits Window {
    width: 560px;
    height: 420px;
    background: Tokens.color-background;

    property <[MessageItem]> turns: [
        { role: MessageRole.user,  id: "brief", text: "Summarize the parser thread." },
        { role: MessageRole.agent, id: "step-1", text: "Reading the thread." },
        { role: MessageRole.agent, id: "step-2", text: "Twelve turns, three of them about spans." },
        { role: MessageRole.agent, id: "summary", anchor: true,
          text: "Here is the summary. The span is dropped in `fold_expr`, which is why every diagnostic under a folded expression points at the file rather than at the line — and why the fix is one line plus a test that folds a nested call." },
    ];

    VerticalLayout {
        padding: 20px;
        spacing: 12px;

        Rectangle {
            // A fixed viewport: the transcript's height must not depend on
            // the line under it that reports what the transcript is doing.
            height: 240px;
            border-width: 1px;
            border-color: Tokens.color-border-hairline;
            border-radius: Tokens.radius-lg;
            clip: true;

            transcript := MessageScroller {
                messages: root.turns;
                // How much of the turn before an anchored one stays on screen
                // above it: landing flush against the top edge reads as
                // content having been cut off.
                anchor-peek: 48px;
            }
        }

        Text {
            text: "Anchor: " + transcript.anchor-id
                + " · turns " + transcript.first-visible-turn
                + "–" + transcript.last-visible-turn + " in view"
                + (transcript.pinned ? " · pinned" : " · released");
            color: Tokens.color-muted-foreground;
            font-size: Tokens.typography-body-sm-size;
        }
    }
}
```

### What arrives is announced

`status-label` is what assistive technology hears when the transcript grows. It defaults to the newest turn’s own text, which is right for a chat and wrong for a transcript whose turns are long or arrive in fragments — override it to summarize, or to translate.

```slint
import { MessageItem, MessageScroller } from "@glint/components/message-scroller.slint";
import { MessageRole } from "@glint/components/message-bubble.slint";
import { Tokens } from "@glint/theme/tokens.slint";

export component Demo inherits Window {
    width: 560px;
    height: 340px;
    background: Tokens.color-background;

    property <[MessageItem]> turns: [
        { role: MessageRole.user,  id: "t1", text: "Read the crate and tell me what it does." },
        { role: MessageRole.agent, id: "t2", text: "It is a themable Slint component library: 74 components, one token layer, and a docs site built from the same sources." },
    ];

    VerticalLayout {
        padding: 20px;

        Rectangle {
            border-width: 1px;
            border-color: Tokens.color-border-hairline;
            border-radius: Tokens.radius-lg;
            clip: true;

            MessageScroller {
                messages: root.turns;
                spacing: 16px;
                content-padding: 20px;
                max-surface-width: 360px;
                jump-to-latest-label: "Jump to the newest turn";
                status-label: "Ada answered.";
            }
        }
    }
}
```

## API Reference

### Properties

| Property               | Type               | Default                                                                        | Description                                                                                                                                                                                                           |
| ---------------------- | ------------------ | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `messages`             | `in [MessageItem]` | no default                                                                     | The transcript, oldest turn first. The consumer owns the model; what the scroller follows is its growth.                                                                                                              |
| `spacing`              | `in length`        | `Tokens.spacing-md`                                                            | Vertical gap between two turns.                                                                                                                                                                                       |
| `content-padding`      | `in length`        | `Tokens.spacing-md`                                                            | Gutter between the bubbles and the viewport edges — it also keeps them clear of the scrollbar.                                                                                                                        |
| `max-surface-width`    | `in length`        | `480px`                                                                        | Widest a bubble's surface may grow, forwarded to every turn.                                                                                                                                                          |
| `jump-to-latest-label` | `in string`        | `@tr("Jump to latest")`                                                        | The jump-to-latest control's name — rendered on it and announced.                                                                                                                                                     |
| `status-label`         | `in string`        | `root.messages.length > 0 ? root.messages[root.messages.length - 1].text : ""` | Announced politely whenever the transcript grows; the newest turn by default, overridable to translate or to summarize.                                                                                               |
| `anchor-peek`          | `in length`        | `64px`                                                                         | How much of the turn before an anchored one stays on screen above it. Landing a turn flush against the top edge reads as content having been cut off; a peek says there is history up there and it is where you were. |
| `pinned`               | `out bool`         | no default                                                                     | True while the viewport follows the newest turn. The scroller owns it: it holds while the view rests at the end of the transcript and releases the moment the reader scrolls away.                                    |
| `first-visible-turn`   | `out int`          | no default                                                                     | The band of turns the reader has in front of them, as positions in `messages`. A host marks turns read, fires receipts or follows the transcript in a side index off these.                                           |
| `last-visible-turn`    | `out int`          | no default                                                                     |                                                                                                                                                                                                                       |
| `anchor-id`            | `out string`       | no default                                                                     | The turn the viewport treats as current — the one at its top edge. Named rather than numbered, so it stays the same turn while history lands above it.                                                                |

### Functions

| Function                     | Description                                                                                                                                                                                            |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `jump-to-latest()`           | Scroll to the newest turn and follow it again — what the jump-to-latest control does, and how a host re-anchors after replacing the model.                                                             |
| `scroll-to-start()`          | Back to the beginning of the conversation. The oldest turn sits at offset zero, so this one landing needs no estimate.                                                                                 |
| `scroll-to-turn(index: int)` | Put the turn at `index` at the top of the viewport — the seek behind "jump to the quoted message". The consumer maps its own id to a position; see the header for why that division is the platform's. |

## Accessibility

- **The transcript is one region.** The scroller carries the `region` role under the translatable name “Transcript”, so a screen reader has one landmark to jump to rather than a wall of turns.
- **The turns are a list.** Inside the region, the scrolling viewport carries the `list` role and the transcript’s whole turn count, so assistive technology can say where in it a turn sits — including the turns that are not instantiated, which a virtualized list otherwise cannot count.
- **An arriving turn is announced politely.** The viewport is a live region carrying `status-label`, so a reader hears the newest turn without polling the transcript and without being interrupted.
- **A virtualized row that is off screen is not in the tree.** Only the turns in view are instantiated, so assistive technology walks what the reader has in front of them — which is why the keyboard has to be able to move the viewport.
- **Keyboard.** The transcript is a stop in the reading order (ADR-0032): the arrows, `Page Up` / `Page Down` and `Home` / `End` walk it. The jump-to-latest control is the next stop, so two `Tab`s reach it — and it is out of the tab order entirely while the viewport is pinned.
- **History does not move the reader.** When turns are prepended, the scroller seeks back to the turn the reader was on, at the offset into it they were already at — so loading history does not yank the transcript out from under someone reading it.
