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;