Theme
On this page (8)
Component Skill — components/session/component-skill.md

Native basis

A focusable scroll region around a role="log" transcript - the keyboard scrolls it natively, new messages are announced; the runtime only decides where the scroll goes.

Web Platform APIs

role="log"scrollendResizeObserveroverflow-anchorcontent-visibilityDrag and Drop API

Classes

.session.session-viewport.session-content.session-item.session-scroll-button.session-footer.session-status

Data attributes

data-default-position (end, start, last-anchor), data-threshold, data-peek, data-anchor (item), data-message-id (item), data-track, data-drop + data-drop-accept / data-drop-label, data-animate; set by the runtime: data-stick, data-scrollable, data-autoscrolling, data-drop-active; events session-drop, session-visibility; API df$.shadcn.session.

§Session

A whole conversation: the transcript follows the newest message while you are at the bottom and lets go when you scroll up - the arrow brings you back. The composer is the Textarea page's .textarea-group with the same script: it grows, + attaches images, Enter sends. Drop images anywhere on the message window (data-drop) and they land in the composer; send, and Sofia types back.

§Assistant: anchored turns and streaming

Ask something: your question is a data-anchor turn - it settles near the top with a peek at the previous answer, instead of the window jumping to the very end - and the reply streams in word by word under the streaming state (aria-busy on the log, following the text while you are at the bottom).

§Load older messages

History arrives above: df$.shadcn.session.prepend() adds older rows and shifts the scroll by exactly their height, so the message you were reading does not move. The session owns this (overflow-anchor: none) - the browser's own anchoring would correct twice.

§Jump to a turn

data-track reports the current turn (the last anchored question in the upper third) and the visible rows as session-visibility - the outline highlights it as you scroll. Click a question: scrollToMessage() anchors it with the peek. The session opens at the last anchored turn (data-default-position='last-anchor').

§Scroll status

Opened at the start (data-default-position='start'): the up button appears once there is something above, the down button while there is something below - each inert when there is nothing in its direction. The status line reads the state and data-scrollable.

§States

Named states via the shared State API, driven per session through the bound api:

  • default - following the live edge: new messages scroll into view
  • detached - not following: the reader scrolled away or a turn anchored; { to: 'start' | messageId } scrolls there
  • streaming - a reply is being written: aria-busy on the log, the transcript follows it at the end

The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/session-{state}.png.

Machine contract - verified against session.schema.json by bun run verify:

StateTypeValuesDefaultDescription
detachedbooleantrue, falsefalseNot following the live edge - the reader scrolled away or a turn anchored.
streamingbooleantrue, falsefalseA reply is being written: aria-busy on the log, following at the end.

§CSS view file

/* -- Session component -------------------------------------------
   A chat session: a scrolling transcript (role="log") that follows the
   live edge, jumps back to it, keeps its place when history loads, anchors
   new turns, and takes dropped files - plus a footer for the composer.
   The runtime keeps the data-* state current; CSS draws from it. */
@layer components {
  .session {
    position: relative;
    display: flex;
    flex-direction: column;
    min-height: 0;
    overflow: hidden;
    border: 1px solid var(--border);
    border-radius: var(--radius-xl);
    background: var(--card);
    color: var(--card-foreground);
  }
  /* -- Viewport: the scroller --------------------------------------- */
  .session-viewport {
    position: relative;
    display: flex;
    flex-direction: column;
    flex: 1;
    min-height: 0;
    padding: 1rem;
    overflow-y: auto;
    overscroll-behavior: contain;
    /* the runtime keeps the reader's place when history prepends - the
       browser's own scroll anchoring would correct twice */
    overflow-anchor: none;
    scrollbar-gutter: stable;
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: -2px;
    }
  }
  /* short threads sit at the bottom, like every chat */
  .session-content {
    display: flex;
    flex-direction: column;
    gap: 1rem;
    margin-block-start: auto;
  }
  /* rows off screen skip rendering; their size is remembered. content-
     visibility implies paint containment, which clips at the row's edge -
     the clip margin lets bubble tails, reactions and focus rings draw
     outside it */
  .session-item {
    content-visibility: auto;
    contain-intrinsic-size: auto 3.5rem;
    overflow-clip-margin: 1rem;
    &[data-current] { scroll-margin-block-start: 1rem; }
  }
  /* data-animate: new rows rise in */
  .session[data-animate] .session-item {
    transition: opacity 250ms ease, translate 250ms ease;
    @starting-style {
      opacity: 0;
      translate: 0 0.5rem;
    }
  }
  /* -- Scroll buttons: float over the transcript, stuck to its edge -- */
  .session-scroll-button {
    position: sticky;
    z-index: 1;
    align-self: center;
    flex: none;
    display: grid;
    place-items: center;
    width: 2.25rem;
    height: 2.25rem;
    margin: 0;
    padding: 0;
    border: 1px solid var(--border);
    border-radius: 999px;
    background: var(--background);
    color: var(--foreground);
    box-shadow: var(--shadow-md);
    cursor: pointer;
    opacity: 0;
    scale: 0.85;
    visibility: hidden;
    transition: opacity 150ms, scale 150ms, visibility 150ms allow-discrete, background-color 150ms;
    /* a chevron, drawn */
    &::before {
      content: "";
      width: 0.5rem;
      height: 0.5rem;
      border-inline-end: 2px solid currentColor;
      border-block-end: 2px solid currentColor;
      rotate: 45deg;
      translate: 0 -0.125rem;
    }
    &:hover { background: var(--accent); color: var(--accent-foreground); }
    &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
    /* to the end: at the bottom edge, overlaying the last row */
    &[data-to="end"] {
      order: 2;
      inset-block-end: 0.5rem;
      margin-block-start: -2.25rem;
    }
    /* to the start: at the top edge, pointing up */
    &[data-to="start"] {
      order: -1;
      inset-block-start: 0.5rem;
      margin-block-end: -2.25rem;
      &::before { rotate: -135deg; translate: 0 0.125rem; }
    }
  }
  .session[data-scrollable~="end"] .session-scroll-button[data-to="end"],
  .session[data-scrollable~="start"] .session-scroll-button[data-to="start"] {
    opacity: 1;
    scale: 1;
    visibility: visible;
  }
  /* -- Footer: the composer, a status line ---------------------------- */
  .session-footer {
    flex: none;
    padding: 0.75rem;
    border-top: 1px solid var(--border);
    background: var(--card);
  }
  .session-status {
    padding: 0.375rem 1rem;
    border-top: 1px solid var(--border);
    color: var(--muted-foreground);
    font-size: 0.75rem;
  }
  /* -- Drop target: files dragged over the session ----------------------- */
  .session[data-drop-active]::after {
    content: attr(data-drop-label);
    position: absolute;
    inset: 0.5rem;
    z-index: 2;
    display: grid;
    place-items: center;
    border: 2px dashed var(--ring);
    border-radius: var(--radius-lg);
    background: color-mix(in oklch, var(--background) 85%, transparent);
    color: var(--foreground);
    font-size: 0.875rem;
    font-weight: 500;
    pointer-events: none;
  }
  /* -- Accessibility -------------------------------------------- */
  @media (prefers-reduced-motion: reduce) {
    .session-scroll-button,
    .session[data-animate] .session-item { transition: none; }
  }
  @media (prefers-contrast: more) {
    .session,
    .session-footer,
    .session-scroll-button { border-color: var(--foreground); }
  }
  @media (forced-colors: active) {
    .session { border: 1px solid CanvasText; }
    .session-scroll-button { border: 1px solid ButtonText; }
    .session[data-drop-active]::after { border-color: Highlight; forced-color-adjust: none; }
  }
}

§JS view file

// -- Session ----------------------------------------------------
// A chat transcript that behaves like one: it follows the live edge while
// the reader is there and lets go the moment they scroll away; a button
// brings them back; history loaded above keeps their place; a new turn can
// anchor near the top with a peek at the one before; files dropped on it
// arrive as an event. The scrolling is native (a focusable region, the
// keyboard's own keys) - this module only decides where to put it.
// Plus the named-state API (AGENTS.md "State API") and df$.shadcn.session.
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals } from '../../shared/state-api.js';
const df$ = defussGlobals();
/** default = following the live edge; detached = the reader scrolled away
 *  (or a turn anchored); streaming = a reply is being written (aria-busy),
 *  the transcript follows it. */
const sessionStates = ['default', 'detached', 'streaming'];
const num = (el, key, fallback) => {
  const v = parseFloat(el.dataset[key]);
  return Number.isFinite(v) ? v : fallback;
};
const reduced = () => globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
const resolve = (t) => (typeof t === 'string' ? document.getElementById(t) ?? document.querySelector(t) : t);
const parts = (s) => ({
  viewport: s.querySelector(':scope > .session-viewport'),
  content: s.querySelector(':scope > .session-viewport > .session-content'),
});
/** How far from the end the reader is, in px. */
const fromEnd = (v) => v.scrollHeight - v.scrollTop - v.clientHeight;
/** Scrolls the viewport without the reader's scroll logic reacting to it. */
function scrollViewport(s, top, smooth) {
  const { viewport } = s._parts;
  s.setAttribute('data-autoscrolling', '');
  viewport.scrollTo({ top, behavior: smooth && !reduced() ? 'smooth' : 'instant' });
  clearTimeout(s._settle);
  // scrollend where the browser has it; a timeout covers the rest
  s._settle = setTimeout(() => settle(s), smooth ? 700 : 50);
}
function settle(s) {
  clearTimeout(s._settle);
  s.removeAttribute('data-autoscrolling');
  measure(s);
}
/** Recomputes what lies beyond each edge and the follow flag. */
function measure(s) {
  const { viewport } = s._parts;
  if (!viewport) return;
  const threshold = num(s, 'threshold', 48);
  const start = viewport.scrollTop > 1;
  const end = fromEnd(viewport) > 1;
  const tokens = [start && 'start', end && 'end'].filter(Boolean).join(' ');
  if (tokens) s.setAttribute('data-scrollable', tokens);
  else s.removeAttribute('data-scrollable');
  s.querySelectorAll('.session-scroll-button').forEach((b) => {
    const active = b.dataset.to === 'start' ? start : end;
    b.dataset.active = String(active);
    b.inert = !active;
  });
  s._height = viewport.scrollHeight;
  // only the reader's own scrolling moves the follow flag
  if (!s.hasAttribute('data-autoscrolling')) {
    // room reserved under an anchored turn is not a live edge to follow
    const reserved = parseFloat(s._parts.content.style.paddingBlockEnd) > 0;
    const stick = fromEnd(viewport) <= threshold && !reserved;
    setStick(s, stick);
  }
  track(s);
}
function setStick(s, stick) {
  s.toggleAttribute('data-stick', stick);
  const name = s.dataset.stateName;
  if (name === 'streaming') return; // streaming stays the named state
  const next = stick ? 'default' : 'detached';
  if (name !== next) {
    s.dataset.stateName = next;
    s._stateConfig = {};
  }
}
/** Keeps a target position while rows settle - see onResize. */
function hold(s, target, ms) {
  s._opening = target;
  clearTimeout(s._holdTimer);
  s._holdTimer = setTimeout(() => { s._opening = null; }, ms);
}
/**
 * Room under the latest anchored turn: a short reply leaves nothing to
 * scroll into, so the turn could never reach the top. The content's end
 * padding is exactly what is missing - it shrinks to 0 as the reply grows
 * past the window.
 */
function anchorSpace(s) {
  const a = s._anchor;
  const { viewport, content } = s._parts;
  if (!a || !content.contains(a)) {
    if (content.style.paddingBlockEnd) content.style.paddingBlockEnd = '';
    s._anchor = null;
    return;
  }
  const pad = parseFloat(content.style.paddingBlockEnd) || 0;
  const vpPad = parseFloat(getComputedStyle(viewport).paddingBlockEnd) || 0;
  // layout offsets, not rects: a row rising in (data-animate) is translated
  const below = content.offsetTop + content.offsetHeight - pad - a.offsetTop;
  const need = Math.max(0, Math.round(viewport.clientHeight - vpPad - num(s, 'peek', 48) - below));
  if (need !== Math.round(pad)) content.style.paddingBlockEnd = need ? `${need}px` : '';
  // measure, don't assume: top up whatever the real scroll range still lacks
  if (need) {
    const short = anchorTop(s, a) - (viewport.scrollHeight - viewport.clientHeight);
    if (short > 0) content.style.paddingBlockEnd = `${need + Math.ceil(short)}px`;
  }
}
/** Where an anchored item settles: near the top, a peek of the previous one. */
const anchorTop = (s, item) => item.offsetTop - num(s, 'peek', 48);
// -- Imperative scrolling ----------------------------------------------------
function scrollToEnd(s, { smooth = true } = {}) {
  s.setAttribute('data-stick', '');
  scrollViewport(s, s._parts.viewport.scrollHeight, smooth);
}
function scrollToStart(s, { smooth = true } = {}) {
  s.removeAttribute('data-stick');
  scrollViewport(s, 0, smooth);
}
function scrollToMessage(s, id, { smooth = true } = {}) {
  const item = s._parts.content.querySelector(`.session-item[data-message-id="${CSS.escape(id)}"]`);
  if (!item) return false;
  s.removeAttribute('data-stick');
  scrollViewport(s, anchorTop(s, item), smooth);
  // rows it passes may only now render at their real size: keep aiming at
  // the message for a moment (the reader's own scroll cancels the hold)
  hold(s, () => anchorTop(s, item), smooth ? 900 : 400);
  return true;
}
/**
 * UI side of setState. 'default' re-engages following and scrolls to the
 * end (ends streaming); 'detached' lets go - `{ to: 'start' | messageId }`
 * scrolls there; 'streaming' marks the log busy and follows the growing
 * reply while the reader is at the end.
 */
function triggerStateChange(s, stateName, config) {
  const { content } = s._parts;
  if (stateName === 'streaming') content.setAttribute('aria-busy', 'true');
  else content.removeAttribute('aria-busy');
  switch (stateName) {
    case 'default':
      scrollToEnd(s, { smooth: config.smooth !== false });
      break;
    case 'detached':
      s.removeAttribute('data-stick');
      if (config.to === 'start') scrollToStart(s);
      else if (typeof config.to === 'string') scrollToMessage(s, config.to);
      break;
    case 'streaming':
      if (s.hasAttribute('data-stick')) scrollToEnd(s, { smooth: false });
      break;
  }
}
/** Registry-level API; pass the .session element explicitly. Unknown names throw. */
export const sessionApi = {
  setState(s, stateName, config = {}) {
    if (!sessionStates.includes(stateName)) {
      throw new Error(`session: unknown state "${stateName}" (supported: ${sessionStates.join(', ')})`);
    }
    s.dataset.stateName = stateName;
    s._stateConfig = config;
    triggerStateChange(s, stateName, config);
  },
  getState(s) {
    return { name: s.dataset.stateName || 'default', config: s._stateConfig ?? {} };
  },
};
df$.sessionApi = sessionApi;
df$.sessionStates = sessionStates;
// -- Reacting to the transcript changing ------------------------------------
/**
 * Items added: a prepend (history) keeps the reader's place; an append
 * follows the live edge when the reader is there - or anchors the turn
 * near the top when it carries data-anchor.
 */
function onItems(s, records) {
  const { viewport } = s._parts;
  const before = s._height ?? viewport.scrollHeight;
  let prepended = false;
  let appended = false;
  let anchored = null;
  for (const r of records) {
    if (!r.addedNodes.length) continue;
    // nothing after the insertion = an append; nothing before it = a prepend
    if (r.nextSibling === null) {
      appended = true;
      for (const node of r.addedNodes) if (node.nodeType === 1 && node.matches('.session-item[data-anchor]')) anchored = node;
    } else if (r.previousSibling === null) prepended = true;
  }
  if (prepended && !appended) {
    // history above: shift by exactly what was added, the visible row stays
    // put. Off-screen rows skip rendering (content-visibility) and count at
    // an estimate - render the new ones once so the shift is their real
    // height; 'auto' then remembers it, so nothing moves later.
    const added = records.flatMap((r) => [...r.addedNodes]).filter((n) => n.nodeType === 1);
    added.forEach((n) => { n.style.contentVisibility = 'visible'; });
    s.setAttribute('data-autoscrolling', '');
    viewport.scrollTop += viewport.scrollHeight - before;
    added.forEach((n) => { n.style.contentVisibility = ''; });
    settle(s);
    return;
  }
  if (anchored) {
    s._anchor = anchored;
    anchorSpace(s);
    s.removeAttribute('data-stick');
    scrollViewport(s, anchorTop(s, anchored), true);
    // rows above may still settle to their real height - keep aiming at the turn
    hold(s, () => anchorTop(s, anchored), 900);
    if (s.dataset.stateName !== 'streaming') { s.dataset.stateName = 'detached'; s._stateConfig = {}; }
    return;
  }
  if (appended && s.hasAttribute('data-stick')) scrollViewport(s, viewport.scrollHeight, s.dataset.stateName !== 'streaming');
  else measure(s);
}
/** Content grew in place (a streaming reply): stay on the live edge. */
function onResize(s) {
  const { viewport } = s._parts;
  anchorSpace(s);
  // opening at the start / an anchor: rows above render at their real size
  // after the first frames - hold the opening position until the reader moves
  if (s._opening) {
    s.setAttribute('data-autoscrolling', '');
    viewport.scrollTop = s._opening();
    settle(s);
    return;
  }
  if (s.hasAttribute('data-stick') && fromEnd(viewport) > 1) {
    s.setAttribute('data-autoscrolling', '');
    viewport.scrollTop = viewport.scrollHeight;
    settle(s);
  } else measure(s);
}
// -- Visibility tracking (data-track: pay only when asked) -------------------
function track(s) {
  if (!s.hasAttribute('data-track')) return;
  const { viewport, content } = s._parts;
  const top = viewport.getBoundingClientRect().top;
  const bottom = top + viewport.clientHeight;
  const items = Array.from(content.querySelectorAll(':scope > .session-item'));
  const visible = items.filter((it) => {
    const r = it.getBoundingClientRect();
    return r.bottom > top && r.top < bottom;
  });
  // the current turn: the last anchor that has reached the upper third
  const line = top + viewport.clientHeight / 3;
  let current = null;
  for (const it of items) {
    if (!it.hasAttribute('data-anchor')) continue;
    if (it.getBoundingClientRect().top <= line) current = it;
    else break;
  }
  current ??= items.find((it) => it.hasAttribute('data-anchor')) ?? null;
  const ids = visible.map((it) => it.dataset.messageId).filter(Boolean);
  const currentId = current?.dataset.messageId ?? null;
  if (currentId === s._currentId && ids.join() === s._visibleIds) return;
  s._currentId = currentId;
  s._visibleIds = ids.join();
  items.forEach((it) => it.toggleAttribute('data-current', it === current));
  s.dispatchEvent(new CustomEvent('session-visibility', { bubbles: true, detail: { currentAnchorId: currentId, visibleMessageIds: ids } }));
}
// -- Drop target (data-drop) ----------------------------------------------------
function bindDrop(s) {
  const hasFiles = (e) => [...(e.dataTransfer?.types ?? [])].includes('Files');
  if (!s.dataset.dropLabel) s.dataset.dropLabel = 'Drop files to attach';
  const accept = (s.dataset.dropAccept || '').split(',').map((a) => a.trim()).filter(Boolean);
  const ok = (file) => !accept.length || accept.some((a) =>
    a.endsWith('/*') ? file.type.startsWith(a.slice(0, -1)) : a.startsWith('.') ? file.name.toLowerCase().endsWith(a.toLowerCase()) : file.type === a);
  s.addEventListener('dragenter', (e) => { if (hasFiles(e)) { e.preventDefault(); s.setAttribute('data-drop-active', ''); } });
  s.addEventListener('dragover', (e) => { if (hasFiles(e)) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; } });
  s.addEventListener('dragleave', (e) => { if (!s.contains(e.relatedTarget)) s.removeAttribute('data-drop-active'); });
  s.addEventListener('drop', (e) => {
    if (!hasFiles(e)) return;
    e.preventDefault();
    s.removeAttribute('data-drop-active');
    const files = [...e.dataTransfer.files].filter(ok);
    if (files.length) s.dispatchEvent(new CustomEvent('session-drop', { bubbles: true, detail: { files } }));
  });
}
/** Back to the live edge: re-engage following (a streaming reply stays streaming). */
function follow(s, options = {}) {
  if (s.dataset.stateName === 'streaming') scrollToEnd(s, options);
  else sessionApi.setState(s, 'default', options);
}
// -- init ---------------------------------------------------------------------
function init() {
  document.querySelectorAll('.session:not([data-init])').forEach((s) => {
    const p = parts(s);
    if (!p.viewport || !p.content) return; // not a session yet
    s.dataset.init = '';
    s._parts = p;
    const { viewport, content } = p;
    // accessible defaults, unless authored
    if (!viewport.hasAttribute('role')) viewport.setAttribute('role', 'region');
    if (!viewport.hasAttribute('aria-label')) viewport.setAttribute('aria-label', 'Messages');
    if (!viewport.hasAttribute('tabindex')) viewport.tabIndex = 0;
    if (!content.hasAttribute('role')) content.setAttribute('role', 'log');
    if (!content.hasAttribute('aria-relevant')) content.setAttribute('aria-relevant', 'additions');
    viewport.addEventListener('scroll', () => {
      if (s.hasAttribute('data-autoscrolling')) return track(s);
      s._opening = null; // the reader moved - the opening position is theirs now
      measure(s);
    }, { passive: true });
    // any reader input ends the opening hold too
    ['wheel', 'touchstart', 'keydown', 'pointerdown'].forEach((type) => viewport.addEventListener(type, () => { s._opening = null; }, { passive: true }));
    viewport.addEventListener('scrollend', () => settle(s));
    new MutationObserver((records) => onItems(s, records)).observe(content, { childList: true });
    new ResizeObserver(() => onResize(s)).observe(content);
    new ResizeObserver(() => measure(s)).observe(viewport);
    s.querySelectorAll('.session-scroll-button').forEach((b) => {
      b.addEventListener('click', () => (b.dataset.to === 'start' ? scrollToStart(s) : follow(s)));
    });
    if (s.hasAttribute('data-drop')) bindDrop(s);
    s.api = {
      setState: (stateName, config) => sessionApi.setState(s, stateName, config),
      getState: () => sessionApi.getState(s),
    };
    // the opening position - applied once, before anyone reads the thread
    s.setAttribute('data-pending-scroll', '');
    s.dataset.stateName = 'default';
    const where = s.dataset.defaultPosition || 'end';
    const last = [...content.querySelectorAll(':scope > .session-item[data-anchor]')].pop();
    if (where === 'start') s._opening = () => 0;
    else if (where === 'last-anchor' && last) s._opening = () => anchorTop(s, last);
    if (s._opening) {
      scrollViewport(s, s._opening(), false);
      // rows settle within the first frames; after that the position is free
      hold(s, s._opening, 1000);
    } else {
      s.setAttribute('data-stick', '');
      scrollViewport(s, viewport.scrollHeight, false);
    }
    s.removeAttribute('data-pending-scroll');
  });
}
// -- df$.shadcn.session: the imperative surface ---------------------------------
/** Wraps content in a .session-item (unless it is one) with an id / anchor. */
function toItem(content, { id, anchor } = {}) {
  let node = content;
  if (typeof content === 'string') node = document.createRange().createContextualFragment(content);
  const single = node instanceof Element && node.classList.contains('session-item');
  const item = single ? node : document.createElement('div');
  if (!single) {
    item.className = 'session-item';
    item.append(node);
  }
  if (id) item.dataset.messageId = id;
  if (anchor) item.setAttribute('data-anchor', '');
  return item;
}
df$.session = {
  /** Adds a message at the end; follows (or anchors) as the session decides. */
  append(t, content, options) {
    const s = resolve(t);
    const item = toItem(content, options);
    s?._parts?.content.append(item);
    return item;
  },
  /** Adds older messages at the start; the reader's place is kept. */
  prepend(t, content, options) {
    const s = resolve(t);
    const items = (Array.isArray(content) ? content : [content]).map((c) => toItem(c, options));
    s?._parts?.content.prepend(...items);
    return items;
  },
  scrollToEnd: (t, o) => { const s = resolve(t); if (s) follow(s, o); },
  scrollToStart: (t, o) => { const s = resolve(t); if (s) scrollToStart(s, o); },
  scrollToMessage: (t, id, o) => { const s = resolve(t); return s ? scrollToMessage(s, id, o) : false; },
  isAtEnd: (t) => { const s = resolve(t); return !!s && fromEnd(s._parts.viewport) <= num(s, 'threshold', 48); },
};
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub