AkurAI Build
Menu

Public repository / master

AkurAI-Framework / site/frontend/ai/chat/thread.js

Latest change 6dcfea4319fde02bfdcdbd5c872f5ee323b2cf65 - Checkpoint WIP + add AkurAI Build CI/CD pipeline by Ólafur Búi Ólafsson

// thread.js — Thread primitive (native port of assistant-ui's Thread).
//
// The conversation container: a scrollable message list with sticky
// auto-scroll, a "scroll to bottom" button that appears when the user scrolls
// away, and an empty state shown before the first message. It owns Message
// handles (from message.js) but knows nothing about the network — index.js
// drives it.
//
// Native ES module — pure DOM, zero dependencies.
//
// Public API:
//   createThread({ emptyState }) -> handle
//     handle.el                 the thread root (append to the page)
//     handle.viewport           the scrolling element
//     handle.list               the message-list element (role="list")
//     handle.addMessage(handle) append a Message handle, returns it
//     handle.append(node)       append an arbitrary node (e.g. a tool placeholder)
//     handle.clear()            remove all messages, restore the empty state
//     handle.isEmpty()          true when no messages have been added
//     handle.scrollToBottom(smooth)  jump to the latest message
//     handle.autoScroll()       scroll to bottom only if already pinned there

import { createMessage } from "./message.js";

function createThread(options = {}) {
  const emptyState = options.emptyState || "Start the conversation below.";

  const el = document.createElement("div");
  el.className = "chat-thread";

  const viewport = document.createElement("div");
  viewport.className = "chat-thread-viewport";
  viewport.tabIndex = 0;
  viewport.setAttribute("aria-label", "Conversation");

  const list = document.createElement("div");
  list.className = "chat-thread-list";
  list.setAttribute("role", "list");

  const empty = document.createElement("div");
  empty.className = "chat-thread-empty";
  empty.innerHTML = `<img class="chat-thread-empty-mark" src="/img/forkbird.png" width="1254" height="1254" alt="" aria-hidden="true"><p>${emptyState}</p>`;

  const toBottom = document.createElement("button");
  toBottom.type = "button";
  toBottom.className = "btn btn-icon chat-thread-tobottom is-hidden";
  toBottom.title = "Scroll to latest";
  toBottom.setAttribute("aria-label", "Scroll to latest");
  toBottom.textContent = "↓";

  viewport.append(empty, list);
  el.append(viewport, toBottom);

  let count = 0;
  // Pinned = the viewport is at (or near) the bottom, so new content auto-scrolls.
  let pinned = true;

  const SLACK = 64; // px tolerance for "at the bottom"

  function atBottom() {
    return viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= SLACK;
  }

  function scrollToBottom(smooth) {
    viewport.scrollTo({ top: viewport.scrollHeight, behavior: smooth ? "smooth" : "auto" });
    pinned = true;
    toBottom.classList.add("is-hidden");
  }

  function autoScroll() {
    if (pinned) scrollToBottom(false);
  }

  viewport.addEventListener("scroll", () => {
    pinned = atBottom();
    toBottom.classList.toggle("is-hidden", pinned || count === 0);
  });
  toBottom.addEventListener("click", () => scrollToBottom(true));

  function showList() {
    if (count === 0) {
      empty.classList.remove("is-hidden");
    } else {
      empty.classList.add("is-hidden");
    }
  }
  showList();

  return {
    el,
    viewport,
    list,
    addMessage(msgHandle) {
      const handle = msgHandle || createMessage({});
      count += 1;
      empty.classList.add("is-hidden");
      list.appendChild(handle.el);
      autoScroll();
      return handle;
    },
    append(node) {
      empty.classList.add("is-hidden");
      list.appendChild(node);
      autoScroll();
      return node;
    },
    clear() {
      list.replaceChildren();
      count = 0;
      pinned = true;
      showList();
      toBottom.classList.add("is-hidden");
    },
    isEmpty() {
      return count === 0;
    },
    scrollToBottom,
    autoScroll,
  };
}

export { createThread };
export default createThread;