Chat transcript scroller with auto-follow at the live edge, anchored turns, default open position, and scroll-to-end/start controls, as a Foldkit submodel.

Preview
Style
Streaming chat
Streaming supportAuto-follow, turn anchoring, jump-to-latest
I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.
That's the classic streaming scroll problem. Wrap your message list in MessageScroller and turn on autoScroll — the viewport pins to the bottom as tokens arrive, so the latest text lands in place. The important part: it only follows while the reader is already at the bottom. The moment they scroll up, their position is preserved.
Okay, but when someone scrolls up to re-read an older answer, I don't want to yank them back down.
You won't. When content they haven't seen arrives below, the scroll button appears at the bottom of the viewport — one tap jumps back to the newest message and re-engages auto-follow.
Demo is scripted. Press Send to stream a reply — scroll up mid-stream to break auto-follow, then use the arrow button to jump back to the latest.
src/demo/views/message-scroller.ts361 lines
import { Effect, Option, Schema as S } from 'effect'
import { Command, Subscription, Update } from 'foldkit'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'

import * as MessageScroller from '../../generated/registry/ui/message-scroller'
import { Bubble } from '../../generated/registry/ui/bubble'
import { button } from '../../generated/registry/ui/button'
import { inputClass } from '../../generated/registry/ui/input'
import { icon } from '../../generated/registry/lib/icons'
import { RotateCw, Send } from 'lucide'

import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'

const Message = defineMessageUnion({
  GotMessageScrollerMessage: { message: MessageScroller.Message },
  TypedChatDraft: { value: S.String },
  SentChatMessage: {},
  AppendedReplyChunk: { messageId: S.String },
  ClickedResetChat: {},
  ClickedJumpToFirstMessage: {},
})

const ChatMessage = S.Struct({
  id: S.String,
  role: S.Literals(['user', 'assistant']),
  text: S.String,
  isStreaming: S.Boolean,
})
type ChatMessage = typeof ChatMessage.Type

/** Scripted opening transcript (adapted from the upstream demo's chat). */
const SCRIPTED: ReadonlyArray<ChatMessage> = [
  {
    id: 'm1',
    role: 'user',
    text: "I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.",
    isStreaming: false,
  },
  {
    id: 'm2',
    role: 'assistant',
    text: "That's the classic streaming scroll problem. Wrap your message list in MessageScroller and turn on autoScroll — the viewport pins to the bottom as tokens arrive, so the latest text lands in place.\n\nThe important part: it only follows while the reader is already at the bottom. The moment they scroll up, their position is preserved.",
    isStreaming: false,
  },
  {
    id: 'm3',
    role: 'user',
    text: "Okay, but when someone scrolls up to re-read an older answer, I don't want to yank them back down.",
    isStreaming: false,
  },
  {
    id: 'm4',
    role: 'assistant',
    text: "You won't. When content they haven't seen arrives below, the scroll button appears at the bottom of the viewport — one tap jumps back to the newest message and re-engages auto-follow.",
    isStreaming: false,
  },
]

/** The canned assistant reply, streamed word-group by word-group. */
const REPLY_WORDS =
  "MessageScrollerItem fixes that with turn anchoring. Your user turns are scroll anchors, so a newly sent message settles near the top of the viewport with the previous exchange peeking above it — the reply grows in below instead of yanking the thread around.\n\nScroll up mid-stream and auto-follow backs off immediately; scroll back to the bottom and it re-engages. That's the whole loop this demo runs.".split(
    /\s+/,
  )

const CHUNK_WORDS = 3
const CHUNK_DELAY = '120 millis'

const StreamReplyChunk = Command.define('StreamReplyChunk', {
  args: { messageId: S.String },
  messages: [Message.AppendedReplyChunk],
  execute: ({ messageId }) =>
    Effect.gen(function* () {
      yield* Effect.sleep(CHUNK_DELAY)
      return Message.AppendedReplyChunk({ messageId })
    }),
})

/** Client-side only: ids minted after interaction never appear in prerendered
 *  HTML, so a module counter stays hydration-safe. */
let mintedId = 0

const mintId = (prefix: string): string => {
  mintedId += 1
  return `${prefix}-${mintedId}`
}

const userBubble = (message: ChatMessage, h: HtmlBuilder<AppMessage>): Html =>
  Bubble<AppMessage>(
    { align: 'end' },
    [Bubble.content<AppMessage>({ className: 'whitespace-pre-wrap' }, [message.text], h)],
    h,
  )

const assistantBubble = (message: ChatMessage, h: HtmlBuilder<AppMessage>): Html =>
  Bubble<AppMessage>(
    { variant: 'secondary', align: 'start' },
    [
      Bubble.content<AppMessage>(
        { className: 'whitespace-pre-wrap' },
        [
          message.text,
          ...(message.isStreaming
            ? [h.span([h.Class('animate-pulse text-muted-foreground')], ['▍'])]
            : []),
        ],
        h,
      ),
    ],
    h,
  )

export const messageScrollerView = (model: Model, h: HtmlBuilder<AppMessage>): Html => {
  const chatMessages = model.chatMessages
  const canSend = model.chatDraft.trim() !== ''
  const firstMessageId = chatMessages[0]?.id ?? ''

  return h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Streaming chat']),
          h.div(
            [
              h.Class(
                'mx-auto flex h-[560px] w-full max-w-xl flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm',
              ),
            ],
            [
              h.div(
                [
                  h.Class(
                    'flex items-center justify-between gap-2 border-b border-border px-4 py-3',
                  ),
                ],
                [
                  h.div(
                    [h.Class('flex min-w-0 flex-col')],
                    [
                      h.span([h.Class('text-sm font-medium')], ['Streaming support']),
                      h.span(
                        [h.Class('truncate text-xs text-muted-foreground')],
                        ['Auto-follow, turn anchoring, jump-to-latest'],
                      ),
                    ],
                  ),
                  h.div(
                    [h.Class('flex shrink-0 items-center gap-1')],
                    [
                      button<AppMessage>(
                        {
                          variant: 'ghost',
                          size: 'xs',
                          isDisabled: firstMessageId === '',
                          onClick: Message.ClickedJumpToFirstMessage(),
                        },
                        'First message',
                        h,
                      ),
                      button<AppMessage>(
                        {
                          variant: 'ghost',
                          size: 'xs',
                          onClick: Message.ClickedResetChat(),
                        },
                        h.span(
                          [h.Class('inline-flex items-center gap-1')],
                          [icon(h, RotateCw, 'size-3'), 'Reset'],
                        ),
                        h,
                      ),
                    ],
                  ),
                ],
              ),
              h.submodel({
                slotId: model.messageScroller.id,
                model: model.messageScroller,
                view: MessageScroller.view<ChatMessage>(),
                viewInputs: {
                  items: chatMessages,
                  itemToKey: (message) => message.id,
                  itemToId: (message) => message.id,
                  itemIsAnchor: (message) => message.role === 'user',
                  itemToView: (message) =>
                    message.role === 'user' ? userBubble(message, h) : assistantBubble(message, h),
                  className: 'flex-1 min-h-0',
                },
                toParentMessage: (message) => Message.GotMessageScrollerMessage({ message }),
              }),
              h.div(
                [h.Class('flex items-center gap-2 border-t border-border px-3 py-2.5')],
                [
                  h.input([
                    h.Type('text'),
                    h.Placeholder('Ask about scroll behavior…'),
                    h.Value(model.chatDraft),
                    h.OnInput((value) => Message.TypedChatDraft({ value })),
                    h.OnKeyDownPreventDefault((key) =>
                      key === 'Enter' && canSend
                        ? Option.some(Message.SentChatMessage())
                        : Option.none(),
                    ),
                    h.Class(`${inputClass} h-9 flex-1`),
                    h.AriaLabel('Message'),
                  ]),
                  button<AppMessage>(
                    {
                      variant: 'default',
                      size: 'icon-sm',
                      isDisabled: !canSend,
                      onClick: Message.SentChatMessage(),
                    },
                    h.span(
                      [h.Class('inline-flex items-center gap-1')],
                      [icon(h, Send, 'size-4'), h.span([h.Class('sr-only')], ['Send'])],
                    ),
                    h,
                  ),
                ],
              ),
            ],
          ),
          h.div(
            [h.Class('px-1 text-center text-xs text-muted-foreground')],
            [
              'Demo is scripted. Press Send to stream a reply — scroll up mid-stream to break auto-follow, then use the arrow button to jump back to the latest.',
            ],
          ),
        ],
      ),
    ],
  )
}

const foldScroller = Update.foldChild({
  update: MessageScroller.update,
  read: (model: State) => Option.some(model.messageScroller),
  write: (model, next) => evo(model, { messageScroller: () => next }),
  toParentMessage: (message) => Message.GotMessageScrollerMessage({ message }),
})

const fields = {
  messageScroller: MessageScroller.Model,
  chatMessages: S.Array(ChatMessage),
  chatDraft: S.String,
}

const stateSchema = S.Struct(fields)
type State = typeof stateSchema.Type

export const subscriptions = Subscription.lift({
  messageScrollerViewportEvents: MessageScroller.subscriptions.viewportEvents,
})<State, typeof Message.GotMessageScrollerMessage.Type>({
  toChildModel: (model) => model.messageScroller,
  toParentMessage: (message) => Message.GotMessageScrollerMessage({ message }),
})

export const slice = defineSlice({
  fields,
  init: {
    messageScroller: MessageScroller.init({
      id: 'message-scroller-demo',
      autoScroll: true,
      defaultScrollPosition: 'end',
    }),
    chatMessages: [...SCRIPTED],
    chatDraft: '',
  },
  messages: [
    Message.GotMessageScrollerMessage,
    Message.TypedChatDraft,
    Message.SentChatMessage,
    Message.AppendedReplyChunk,
    Message.ClickedResetChat,
    Message.ClickedJumpToFirstMessage,
  ],
  handlers: (model: State) => ({
    GotMessageScrollerMessage: (
      payload: typeof Message.GotMessageScrollerMessage.Type,
    ): UpdateReturn => foldScroller(model, payload.message),
    TypedChatDraft: (payload: typeof Message.TypedChatDraft.Type): UpdateReturn => ({
      model: evo(model, { chatDraft: () => payload.value }),
    }),
    SentChatMessage: (): UpdateReturn => {
      const text = model.chatDraft.trim()
      if (text === '') return { model }
      const userId = mintId('user')
      const replyId = mintId('reply')
      return {
        model: evo(model, {
          chatDraft: () => '',
          chatMessages: () => [
            ...model.chatMessages,
            { id: userId, role: 'user', text, isStreaming: false } satisfies ChatMessage,
            {
              id: replyId,
              role: 'assistant',
              text: '',
              isStreaming: true,
            } satisfies ChatMessage,
          ],
        }),
        commands: [StreamReplyChunk({ messageId: replyId })],
      }
    },
    AppendedReplyChunk: (payload: typeof Message.AppendedReplyChunk.Type): UpdateReturn => {
      const target = model.chatMessages.find((message) => message.id === payload.messageId)
      if (target === undefined || !target.isStreaming) return { model }
      const nextCount = (target.text.match(/\S+/g)?.length ?? 0) + CHUNK_WORDS
      const isDone = nextCount >= REPLY_WORDS.length
      return {
        model: evo(model, {
          chatMessages: () =>
            model.chatMessages.map((message) =>
              message.id === payload.messageId
                ? {
                    ...message,
                    text: REPLY_WORDS.slice(0, nextCount).join(' '),
                    isStreaming: !isDone,
                  }
                : message,
            ),
        }),
        commands: isDone ? [] : [StreamReplyChunk({ messageId: payload.messageId })],
      }
    },
    ClickedResetChat: (): UpdateReturn => ({
      model: evo(model, {
        chatMessages: () => [...SCRIPTED],
        chatDraft: () => '',
      }),
    }),
    ClickedJumpToFirstMessage: (): UpdateReturn => {
      const messageId = model.chatMessages[0]?.id
      if (messageId === undefined) return { model }
      const { model: next, commands } = MessageScroller.scrollToMessage(
        model.messageScroller,
        messageId,
        { align: 'start', behavior: 'smooth' },
      )
      return {
        model: evo(model, { messageScroller: () => next }),
        commands: Command.mapMessages(commands, (message) =>
          Message.GotMessageScrollerMessage({ message }),
        ),
      }
    },
  }),
  samples: [
    Message.TypedChatDraft({ value: 'How do I stop the jump?' }),
    Message.SentChatMessage(),
    Message.ClickedResetChat(),
  ],
  subscriptions,
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/message-scroller

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/ui/message-scroller.ts711 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update/subscriptions into your app:
 *  `import * as MessageScroller from '@/components/ui/message-scroller'`
 */
import { Cause, Effect, Queue, Schema as S, Stream } from 'effect'
import * as Command from 'foldkit/command'
import type { Html } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { defineView } from 'foldkit/submodel'
import { evo } from 'foldkit/struct'
import * as Subscription from 'foldkit/subscription'
import * as Update from 'foldkit/update'

import { buttonSizes, buttonVariants } from '@/ui/button'
import { icon } from '@/lib/icons'
import { ArrowDown } from 'lucide'
import { cn } from '@/lib/utils'

// MessageScroller is a chat transcript scroller. It owns the scroll viewport:
// where a transcript opens (defaultScrollPosition), whether to follow the live
// end while streaming (autoScroll), when a newly appended anchored turn should
// settle near the top with the previous turn peeking above it, and the
// scroll-to-end/start button that appears when overflow exists toward its
// direction. Items are plain rows; anchor them with `itemIsAnchor` and give
// them stable ids with `itemToId` for scrollToMessage.
//
// foldcn gaps vs upstream: @foldkit/ui has no message-scroller primitive, so
// this is a foldkit submodel. No IntersectionObserver visibility tracking
// (currentAnchorId / visibleMessageIds), no prepend preservation, and no
// anchored-turn tail spacer — a reply streaming below an anchored turn is not
// held in place while it grows (the anchor scrolls once; follow resumes when
// the reply reaches the live edge).

/** Upstream class strings, character-identical to bases/base/ui. The
 *  cn-message-scroller* tokens other than -content are upstream no-op hooks
 *  (defined by no style); the resolver strips them. */
export const messageScrollerClass =
  'group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden'

export const messageScrollerViewportClass =
  'size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent'

export const messageScrollerContentClass =
  'gap-6 flex h-max min-h-full flex-col'

export const messageScrollerItemClass =
  'min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]'

export const messageScrollerButtonClass =
  'absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180'

/** Distance from an edge that still counts as at-top/at-bottom. Upstream
 *  default: a sub-pixel tolerance so edge detection does not flicker across
 *  engines that round scrollTop differently. */
export const DEFAULT_SCROLL_EDGE_THRESHOLD = 8

/** Pixels of the previous turn kept visible above a newly anchored turn.
 *  Upstream default scrollPreviousItemPeek. */
export const SCROLL_PREVIOUS_ITEM_PEEK = 64

// MODEL

export const DefaultScrollPosition = S.Literals(['start', 'end', 'last-anchor'])
export type DefaultScrollPosition = typeof DefaultScrollPosition.Type

export const ScrollDirection = S.Literals(['start', 'end'])
export type ScrollDirection = typeof ScrollDirection.Type

export const ScrollMode = S.Literals(['auto', 'smooth'])
export type ScrollMode = typeof ScrollMode.Type

export const Model = S.Struct({
  id: S.String,
  autoScroll: S.Boolean,
  defaultScrollPosition: DefaultScrollPosition,
  scrollEdgeThreshold: S.Number,
  /** Live viewport snapshot from the last scroll/resize event. */
  scrollTop: S.Number,
  viewportHeight: S.Number,
  contentHeight: S.Number,
  /** False until the first viewport measurement arrives; the view renders the
   *  button inactive until then. */
  measured: S.Boolean,
  /** defaultScrollPosition is applied once, on the first non-empty render. */
  initialPositionApplied: S.Boolean,
  /** While true the viewport emits data-autoscrolling (transparent scrollbar
   *  chrome) — set when a programmatic scroll starts, cleared on completion. */
  autoscrolling: S.Boolean,
  /** Version-based cancellation, same scheme as VirtualList: each scroll
   *  command increments this; only the matching completion clears
   *  autoscrolling. */
  pendingScrollVersion: S.Number,
})
export type Model = typeof Model.Type

// MESSAGES

export const Message = defineMessageUnion({
  /** A scroll or resize settled the viewport; carries a fresh snapshot read
   *  from the element. Emitted by the subscription's scroll listener and both
   *  ResizeObservers. */
  SyncedViewport: {
    scrollTop: S.Number,
    viewportHeight: S.Number,
    contentHeight: S.Number,
  },
  /** A scroll-anchored turn appended below the reading position; the update
   *  settles it near the top with the previous turn peeking above. */
  AppendedScrollAnchor: { messageId: S.String },
  /** A scroll command (ApplyScroll / ApplyScrollToMessage) finished. */
  CompletedApplyScroll: { version: S.Number },
  /** The built-in scroll button was pressed. */
  RequestedScroll: { direction: ScrollDirection, behavior: ScrollMode },
})
export type Message = typeof Message.Type

// INIT / UPDATE

export type InitConfig = Readonly<{
  id: string
  /** Follow new content at the bottom while the viewport is already at the
   *  end. Upstream default false. */
  autoScroll?: boolean
  /** Opening position on the first non-empty render, applied once. Upstream
   *  default 'end'. */
  defaultScrollPosition?: DefaultScrollPosition
  /** Distance from an edge that still counts as at-top/at-bottom. Upstream
   *  default 8. */
  scrollEdgeThreshold?: number
}>

/** Creates an initial message-scroller model. The viewport starts unmeasured;
 *  the first ResizeObserver tick applies defaultScrollPosition. */
export const init = (config: InitConfig): Model => ({
  id: config.id,
  autoScroll: config.autoScroll ?? false,
  defaultScrollPosition: config.defaultScrollPosition ?? 'end',
  scrollEdgeThreshold: config.scrollEdgeThreshold ?? DEFAULT_SCROLL_EDGE_THRESHOLD,
  scrollTop: 0,
  viewportHeight: 0,
  contentHeight: 0,
  measured: false,
  initialPositionApplied: false,
  autoscrolling: false,
  pendingScrollVersion: 0,
})

// COMMANDS

/** Scrolls the viewport element. `behavior: 'smooth'` animates via
 *  scrollTo(); 'auto' jumps by setting scrollTop directly. Emits
 *  CompletedApplyScroll with the version it was issued under. */
const ApplyScroll = Command.define('ApplyScroll', {
  args: { id: S.String, scrollTop: S.Number, version: S.Number, behavior: ScrollMode },
  messages: [Message.CompletedApplyScroll],
  execute: ({ id, scrollTop, version, behavior }) =>
    Effect.sync(() => {
      const viewport = document.getElementById(id)
      if (viewport !== null) {
        if (behavior === 'smooth') {
          viewport.scrollTo({ top: scrollTop, behavior: 'smooth' })
        } else {
          viewport.scrollTop = scrollTop
        }
      }
      return Message.CompletedApplyScroll({ version })
    }),
})

/** Scrolls a transcript row into view. Aligns per `align` with `margin`
 *  pixels of content kept visible on the aligned edge ('start' + margin 64
 *  leaves the previous turn peeking above, upstream's keepPreviousPeek).
 *  Emits CompletedApplyScroll even when the row is missing so a pending
 *  autoscrolling flag still clears. */
const ApplyScrollToMessage = Command.define('ApplyScrollToMessage', {
  args: {
    id: S.String,
    messageId: S.String,
    align: S.Literals(['start', 'center', 'end', 'nearest']),
    margin: S.Number,
    version: S.Number,
    behavior: ScrollMode,
  },
  messages: [Message.CompletedApplyScroll],
  execute: ({ id, messageId, align, margin, version, behavior }) =>
    Effect.sync(() => {
      const viewport = document.getElementById(id)
      const element =
        viewport !== null && typeof CSS !== 'undefined'
          ? viewport.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`)
          : null
      if (viewport !== null && element !== null) {
        const viewportTop = viewport.getBoundingClientRect().top
        const rect = element.getBoundingClientRect()
        const itemTop = rect.top - viewportTop + viewport.scrollTop
        const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
        const towardStart = Math.max(0, itemTop - margin)
        const towardEnd = Math.max(0, itemTop + rect.height - viewport.clientHeight + margin)
        const towardCenter = Math.max(
          0,
          itemTop - (viewport.clientHeight - rect.height) / 2 + margin / 2,
        )
        const scrollTop =
          align === 'start'
            ? towardStart
            : align === 'end'
              ? towardEnd
              : align === 'center'
                ? towardCenter
                : Math.abs(towardStart - viewport.scrollTop) <=
                    Math.abs(towardEnd - viewport.scrollTop)
                  ? towardStart
                  : towardEnd
        const clamped = Math.min(maxScroll, scrollTop)
        if (behavior === 'smooth') {
          viewport.scrollTo({ top: clamped, behavior: 'smooth' })
        } else {
          viewport.scrollTop = clamped
        }
      }
      return Message.CompletedApplyScroll({ version })
    }),
})

/** Locates the last scroll-anchored turn and opens on it: a short last turn
 *  already fits below the anchor, so the viewport scrolls to the end;
 *  otherwise the anchor settles near the top with the previous turn peeking
 *  above. Emits CompletedApplyScroll so autoscrolling clears either way. */
const ApplyLastAnchor = Command.define('ApplyLastAnchor', {
  args: { id: S.String, version: S.Number },
  messages: [Message.CompletedApplyScroll],
  execute: ({ id, version }) =>
    Effect.sync(() => {
      const viewport = document.getElementById(id)
      const content = document.getElementById(`${id}-content`)
      const anchors =
        content !== null ? Array.from(content.querySelectorAll('[data-scroll-anchor="true"]')) : []
      const anchor = anchors[anchors.length - 1] ?? null
      if (viewport === null || anchor === null) {
        if (viewport !== null) {
          viewport.scrollTop = viewport.scrollHeight
        }
        return Message.CompletedApplyScroll({ version })
      }
      const viewportTop = viewport.getBoundingClientRect().top
      const anchorTop = anchor.getBoundingClientRect().top - viewportTop + viewport.scrollTop
      const contentBottom = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
      const lastTurnFits = contentBottom - anchorTop <= viewport.clientHeight
      const scrollTop = lastTurnFits
        ? contentBottom
        : Math.max(0, anchorTop - SCROLL_PREVIOUS_ITEM_PEEK)
      viewport.scrollTop = scrollTop
      return Message.CompletedApplyScroll({ version })
    }),
})

// UPDATE

type UpdateReturn = Update.Return<Model, Message>

const issueScroll = (model: Model, scrollTop: number, behavior: ScrollMode): UpdateReturn => {
  const version = model.pendingScrollVersion + 1
  return {
    model: evo(model, {
      autoscrolling: () => true,
      pendingScrollVersion: () => version,
    }),
    commands: [ApplyScroll({ id: model.id, scrollTop, version, behavior })],
  }
}

const issueScrollToMessage = (
  model: Model,
  messageId: string,
  align: 'start' | 'center' | 'end' | 'nearest',
  margin: number,
  behavior: ScrollMode,
): UpdateReturn => {
  const version = model.pendingScrollVersion + 1
  return {
    model: evo(model, {
      autoscrolling: () => true,
      pendingScrollVersion: () => version,
    }),
    commands: [ApplyScrollToMessage({ id: model.id, messageId, align, margin, version, behavior })],
  }
}

const endScrollTop = (viewportHeight: number, contentHeight: number): number =>
  Math.max(0, contentHeight - viewportHeight)

/** Applies the opening position once, on the first non-empty render. */
const initialPosition = (model: Model, snapshot: Model): UpdateReturn => {
  const version = model.pendingScrollVersion + 1
  const opened = evo(model, {
    initialPositionApplied: () => true,
    autoscrolling: () => true,
    pendingScrollVersion: () => version,
  })
  switch (model.defaultScrollPosition) {
    case 'last-anchor':
      return { model: opened, commands: [ApplyLastAnchor({ id: model.id, version })] }
    case 'start':
      return {
        model: evo(opened, { scrollTop: () => 0 }),
        commands: [ApplyScroll({ id: model.id, scrollTop: 0, version, behavior: 'auto' })],
      }
    case 'end':
      return {
        model: evo(opened, {
          scrollTop: () => endScrollTop(snapshot.viewportHeight, snapshot.contentHeight),
        }),
        commands: [
          ApplyScroll({
            id: model.id,
            scrollTop: endScrollTop(snapshot.viewportHeight, snapshot.contentHeight),
            version,
            behavior: 'auto',
          }),
        ],
      }
  }
}

/** Processes a message-scroller message and returns the next model and any
 *  scroll commands. */
export const update = (model: Model, message: Message): UpdateReturn => {
  switch (message._tag) {
    case 'SyncedViewport': {
      const snapshot = evo(model, {
        scrollTop: () => message.scrollTop,
        viewportHeight: () => message.viewportHeight,
        contentHeight: () => message.contentHeight,
        measured: () => true,
      })
      if (!model.initialPositionApplied && message.contentHeight > 0) {
        return initialPosition(model, snapshot)
      }
      // Follow the live end only while the reader already is there: the
      // previous snapshot decides, so scrolling away is a deliberate opt-out.
      const wasAtEnd =
        model.measured &&
        endScrollTop(model.viewportHeight, model.contentHeight) - model.scrollTop <=
          model.scrollEdgeThreshold
      const grew = model.measured && message.contentHeight > model.contentHeight + 0.5
      if (model.autoScroll && wasAtEnd && grew) {
        return issueScroll(
          snapshot,
          endScrollTop(message.viewportHeight, message.contentHeight),
          'auto',
        )
      }
      return { model: snapshot }
    }
    case 'AppendedScrollAnchor':
      return issueScrollToMessage(
        model,
        message.messageId,
        'start',
        SCROLL_PREVIOUS_ITEM_PEEK,
        'auto',
      )
    case 'CompletedApplyScroll':
      return {
        model:
          message.version === model.pendingScrollVersion
            ? evo(model, { autoscrolling: () => false })
            : model,
      }
    case 'RequestedScroll':
      if (!model.measured) return { model }
      return message.direction === 'end'
        ? issueScroll(
            model,
            endScrollTop(model.viewportHeight, model.contentHeight),
            message.behavior,
          )
        : issueScroll(model, 0, message.behavior)
  }
}

// PUBLIC SCROLL COMMANDS

/** Scrolls the viewport to its end. Default behavior: smooth. */
export const scrollToEnd = (model: Model, options?: { behavior?: ScrollMode }): UpdateReturn =>
  issueScroll(
    model,
    endScrollTop(model.viewportHeight, model.contentHeight),
    options?.behavior ?? 'smooth',
  )

/** Scrolls the viewport to its start. Default behavior: smooth. */
export const scrollToStart = (model: Model, options?: { behavior?: ScrollMode }): UpdateReturn =>
  issueScroll(model, 0, options?.behavior ?? 'smooth')

/** Scrolls a transcript row into view. `options.align` picks the viewport
 *  edge (or center) to align the row with; `options.scrollMargin` keeps that
 *  many pixels of content visible on the aligned edge ('start' + 64 leaves
 *  the previous turn peeking above). Default: smooth, start, no margin. */
export const scrollToMessage = (
  model: Model,
  messageId: string,
  options?: {
    align?: 'start' | 'center' | 'end' | 'nearest'
    behavior?: ScrollMode
    scrollMargin?: number
  },
): UpdateReturn =>
  issueScrollToMessage(
    model,
    messageId,
    options?.align ?? 'start',
    options?.scrollMargin ?? 0,
    options?.behavior ?? 'smooth',
  )

/** True while content is hidden above the viewport. */
export const atStart = (model: Model): boolean =>
  !model.measured || model.scrollTop <= model.scrollEdgeThreshold

/** True while content is hidden below the viewport. */
export const atEnd = (model: Model): boolean =>
  !model.measured ||
  endScrollTop(model.viewportHeight, model.contentHeight) - model.scrollTop <=
    model.scrollEdgeThreshold

// VIEW

export type ViewInputs<Item> = Readonly<{
  items: ReadonlyArray<Item>
  itemToKey: (item: Item, index: number) => string
  itemToView: (item: Item, index: number) => Html
  /** Stable row id for scrollToMessage, anchored turns, and future
   *  visibility — emitted as data-message-id. */
  itemToId?: (item: Item, index: number) => string | undefined
  /** Marks turn boundaries that newly appended turns anchor from and that
   *  defaultScrollPosition 'last-anchor' restores — emitted as
   *  data-scroll-anchor. */
  itemIsAnchor?: (item: Item, index: number) => boolean
  className?: string
  /** The built-in scroll button's direction; 'none' omits it. Default
   *  'end'. */
  buttonDirection?: ScrollDirection | 'none'
}>

/** Renders the transcript scroller. Embedded via `h.submodel` inside a sized
 *  container: the root is `size-full`, so give it a definite height (pass
 *  `className: 'flex-1 min-h-0'` inside a flex column, for example). */
export const view = <Item>() =>
  defineView<Model, Message, ViewInputs<Item>>((model, viewInputs, h) => {
    const maxScroll = Math.max(0, model.contentHeight - model.viewportHeight)
    const overflowTowardEnd =
      model.measured && maxScroll - model.scrollTop > model.scrollEdgeThreshold
    const overflowTowardStart = model.measured && model.scrollTop > model.scrollEdgeThreshold
    const buttonDirection = viewInputs.buttonDirection ?? 'end'

    const scrollButton = (direction: ScrollDirection) =>
      h.button(
        [
          h.Type('button'),
          h.DataAttribute('slot', 'message-scroller-button'),
          h.DataAttribute('direction', direction),
          h.DataAttribute('variant', 'secondary'),
          h.DataAttribute('size', 'icon-sm'),
          h.DataAttribute(
            'active',
            (direction === 'start' ? overflowTowardStart : overflowTowardEnd) ? 'true' : 'false',
          ),
          h.Inert(!(direction === 'start' ? overflowTowardStart : overflowTowardEnd)),
          ...(direction === 'start'
            ? overflowTowardStart
              ? []
              : [h.Tabindex(-1)]
            : overflowTowardEnd
              ? []
              : [h.Tabindex(-1)]),
          h.Class(
            cn(
              'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=\'size-\'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50',
              buttonVariants.secondary,
              buttonSizes['icon-sm'],
              messageScrollerButtonClass,
            ),
          ),
          h.OnClick(Message.RequestedScroll({ direction, behavior: 'smooth' })),
        ],
        [
          icon(h, ArrowDown),
          h.span([h.Class('sr-only')], [direction === 'end' ? 'Scroll to end' : 'Scroll to start']),
        ],
      )

    return h.div(
      [
        h.Class(cn(messageScrollerClass, viewInputs.className)),
        h.DataAttribute('slot', 'message-scroller'),
      ],
      [
        h.div(
          [
            h.Id(model.id),
            h.Role('region'),
            h.AriaLabel('Messages'),
            h.Tabindex(0),
            h.Class(messageScrollerViewportClass),
            h.DataAttribute('slot', 'message-scroller-viewport'),
            ...(model.autoscrolling ? [h.DataAttribute('autoscrolling', '')] : []),
          ],
          [
            h.div(
              [
                h.Id(`${model.id}-content`),
                h.Role('log'),
                h.AriaRelevant('additions'),
                h.Class(messageScrollerContentClass),
                h.DataAttribute('slot', 'message-scroller-content'),
              ],
              viewInputs.items.map((item, index) => {
                const itemId = viewInputs.itemToId?.(item, index)
                return h.keyed('div')(
                  viewInputs.itemToKey(item, index),
                  [
                    h.Class(messageScrollerItemClass),
                    h.DataAttribute('slot', 'message-scroller-item'),
                    ...(itemId === undefined ? [] : [h.DataAttribute('message-id', itemId)]),
                    h.DataAttribute(
                      'scroll-anchor',
                      viewInputs.itemIsAnchor?.(item, index) ? 'true' : 'false',
                    ),
                  ],
                  [viewInputs.itemToView(item, index)],
                )
              }),
            ),
          ],
        ),
        ...(buttonDirection === 'none' ? [] : [scrollButton(buttonDirection)]),
      ],
    )
  })

// SUBSCRIPTIONS

type ViewportEvent = typeof Message.Type

/** Subscriptions that drive the viewport snapshot and anchored-turn
 *  detection.
 *
 *  - **scroll**: listens for `scroll` events on the viewport element.
 *  - **resize**: ResizeObservers on the viewport and the content element
 *    emit fresh snapshots as the transcript grows or shrinks.
 *  - **anchors**: a MutationObserver on the content detects newly appended
 *    scroll-anchored turns (ids seen for the first time since attach) whose
 *    top edge sits below the viewport's, and emits AppendedScrollAnchor.
 *
 *  A document-level MutationObserver re-reconciles listeners when the
 *  elements are (re)inserted, so SPA navigation reattaches without consumer
 *  cooperation. Anchor bookkeeping resets on reattach. */
export const subscriptions = Subscription.make<Model, Message>()((entry) => ({
  viewportEvents: entry(
    { id: S.String },
    {
      modelToDependencies: (model) => ({ id: model.id }),
      dependenciesToStream: ({ id }) =>
        Stream.callback((queue: Queue.Queue<ViewportEvent, Cause.Done>) =>
          Effect.acquireRelease(
            Effect.sync(() => attachViewportEvents(id, queue)),
            (state) => Effect.sync(() => teardownViewportEvents(state)),
          ).pipe(Effect.flatMap(() => Effect.never)),
        ),
    },
  ),
}))

type AttachedViewport = {
  queue: Queue.Queue<ViewportEvent, Cause.Done>
  id: string
  viewport: HTMLElement | null
  scrollListener: (() => void) | null
  viewportResizeObserver: ResizeObserver | null
  contentResizeObserver: ResizeObserver | null
  anchorMutationObserver: MutationObserver | null
  documentObserver: MutationObserver
  handledAnchors: Set<string>
  pendingFrame: number | null
}

const snapshotMessage = (viewport: HTMLElement): ViewportEvent =>
  Message.SyncedViewport({
    scrollTop: viewport.scrollTop,
    viewportHeight: viewport.clientHeight,
    contentHeight: viewport.scrollHeight,
  })

const offerEvent = (state: AttachedViewport, message: ViewportEvent): void => {
  void Queue.offerUnsafe(state.queue, message)
}

/** Disconnects the document-level reconcile observer and the per-element
 *  listeners. Runs once, at subscription teardown. */
const teardownViewportEvents = (state: AttachedViewport): void => {
  state.documentObserver.disconnect()
  if (state.pendingFrame !== null) {
    cancelAnimationFrame(state.pendingFrame)
    state.pendingFrame = null
  }
  detachViewportEvents(state)
}

/** Drops the per-element listeners and observers. The document-level
 *  reconcile observer is untouched — it must survive unmounts to reattach on
 *  remount. */
const detachViewportEvents = (state: AttachedViewport): void => {
  state.viewportResizeObserver?.disconnect()
  state.contentResizeObserver?.disconnect()
  state.anchorMutationObserver?.disconnect()
  if (state.viewport !== null && state.scrollListener !== null) {
    state.viewport.removeEventListener('scroll', state.scrollListener)
  }
  state.viewport = null
  state.scrollListener = null
  state.viewportResizeObserver = null
  state.contentResizeObserver = null
  state.anchorMutationObserver = null
  state.handledAnchors = new Set()
}

/** Wires the scroll listener, both ResizeObservers, and the anchor
 *  MutationObserver onto a mounted viewport/content pair. */
const connectViewport = (state: AttachedViewport): void => {
  const viewport = document.getElementById(state.id)
  const content = document.getElementById(`${state.id}-content`)
  if (viewport === null || content === null) return
  state.viewport = viewport
  state.scrollListener = () => offerEvent(state, snapshotMessage(viewport))
  viewport.addEventListener('scroll', state.scrollListener, { passive: true })
  state.viewportResizeObserver = new ResizeObserver(() =>
    offerEvent(state, snapshotMessage(viewport)),
  )
  state.viewportResizeObserver.observe(viewport)
  state.contentResizeObserver = new ResizeObserver(() =>
    offerEvent(state, snapshotMessage(viewport)),
  )
  state.contentResizeObserver.observe(content)
  // Seed with the anchors present at connect time so restored transcripts do
  // not anchor-scroll on mount; only ids first seen afterwards emit.
  state.handledAnchors = new Set(
    Array.from(content.querySelectorAll('[data-scroll-anchor="true"]'))
      .map((anchor) => anchor.getAttribute('data-message-id'))
      .filter((value): value is string => value !== null),
  )
  state.anchorMutationObserver = new MutationObserver(() => {
    const anchors = content.querySelectorAll('[data-scroll-anchor="true"]')
    const last = anchors.length === 0 ? null : anchors.item(anchors.length - 1)
    if (last === null) return
    const messageId = last.getAttribute('data-message-id')
    if (messageId === null || state.handledAnchors.has(messageId)) return
    state.handledAnchors.add(messageId)
    // Only turns appended below the reading position anchor the scroll; a
    // turn already above (restored transcripts, prepended history) does not
    // yank the reader back.
    if (last.getBoundingClientRect().top > viewport.getBoundingClientRect().top) {
      offerEvent(state, Message.AppendedScrollAnchor({ messageId }))
    }
  })
  state.anchorMutationObserver.observe(content, { childList: true })
}

const attachViewportEvents = (
  id: string,
  queue: Queue.Queue<ViewportEvent, Cause.Done>,
): AttachedViewport => {
  const state: AttachedViewport = {
    queue,
    id,
    viewport: null,
    scrollListener: null,
    viewportResizeObserver: null,
    contentResizeObserver: null,
    anchorMutationObserver: null,
    handledAnchors: new Set(),
    pendingFrame: null,
    documentObserver: new MutationObserver(() => {}),
  }

  const reconcile = (): void => {
    const viewport = document.getElementById(state.id)
    const content = document.getElementById(`${state.id}-content`)
    if (viewport === null || content === null) {
      detachViewportEvents(state)
      return
    }
    if (state.viewport === viewport) return
    connectViewport(state)
  }

  // Reattach on remount: the container can be inserted/removed by any parent
  // (route changes, conditional renders). Reconcile is gated by rAF and
  // short-circuits while the cached element is still connected.
  state.documentObserver = new MutationObserver(() => {
    if (state.pendingFrame !== null) return
    state.pendingFrame = requestAnimationFrame(() => {
      state.pendingFrame = null
      reconcile()
    })
  })
  state.documentObserver.observe(document.body, { childList: true, subtree: true })
  reconcile()
  return state
}