(function () {
"use strict";
var currentVersion = 0;
var userScrolledUp = false;
var previousBlockCount = 0;
var pollTimer = null;
var ws = null;
var wsPort = null;
var wsFailCount = 0;
var wsMaxRetries = 3;
var usingWebSocket = false;
var openChats = {}; var chatInputDrafts = {}; var chatMessages = []; var selectionContext = null;
var boardTitle = document.getElementById("board-title");
var stepCount = document.getElementById("step-count");
var boardContent = document.getElementById("board-content");
var sendBtn = document.getElementById("send-btn");
var toast = document.getElementById("toast");
function connectWebSocket() {
if (!wsPort) return;
if (wsFailCount >= wsMaxRetries) {
startPolling();
return;
}
var wsUrl = "ws://" + window.location.hostname + ":" + wsPort;
try {
ws = new WebSocket(wsUrl);
} catch (e) {
wsFailCount++;
startPolling();
return;
}
ws.onopen = function () {
wsFailCount = 0;
usingWebSocket = true;
stopPolling();
};
ws.onmessage = function (event) {
try {
var data = JSON.parse(event.data);
if (data.type === "chat_update") {
console.log("[ws] Received chat_update via WebSocket:", (data.messages || []).length, "messages");
chatMessages = data.messages || [];
renderChatMessages();
stopChatPoll(); } else {
if (data.version > currentVersion) {
update(data);
currentVersion = data.version;
}
}
} catch (e) {
}
};
ws.onclose = function () {
usingWebSocket = false;
ws = null;
wsFailCount++;
if (wsFailCount < wsMaxRetries) {
setTimeout(connectWebSocket, 1000);
} else {
startPolling();
}
};
ws.onerror = function () {
if (ws) {
ws.close();
}
};
}
function startPolling() {
if (!pollTimer) {
poll();
}
}
function stopPolling() {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
}
async function poll() {
try {
var url = "/board";
if (currentVersion > 0) {
url += "?v=" + currentVersion;
}
var res = await fetch(url);
if (res.status === 304) {
schedulePoll();
return;
}
if (!res.ok) {
schedulePoll();
return;
}
var data = await res.json();
if (data.ws_port && !usingWebSocket && wsFailCount < wsMaxRetries) {
wsPort = data.ws_port;
connectWebSocket();
}
if (data.version > currentVersion) {
update(data);
currentVersion = data.version;
}
} catch (e) {
}
schedulePoll();
}
function schedulePoll() {
if (pollTimer) clearTimeout(pollTimer);
pollTimer = setTimeout(poll, 500);
}
var lastBlocksHtml = "";
function update(data) {
if (data.title) {
boardTitle.textContent = data.title;
document.title = data.title + " - cliboard";
}
if (data.blocks_html === lastBlocksHtml) {
return;
}
var sel = window.getSelection();
if (sel && !sel.isCollapsed && getStepAncestor(sel.anchorNode)) {
return;
}
lastBlocksHtml = data.blocks_html;
boardContent.innerHTML = data.blocks_html;
var steps = boardContent.querySelectorAll(".step");
var count = steps.length;
if (count > 0) {
stepCount.textContent = count + (count === 1 ? " step" : " steps");
} else {
stepCount.textContent = "";
}
if (count > previousBlockCount) {
for (var i = previousBlockCount; i < count; i++) {
steps[i].classList.add("new");
}
}
previousBlockCount = count;
injectChatUI();
if (!userScrolledUp) {
scrollToBottom();
}
}
function scrollToBottom() {
window.scrollTo({
top: document.body.scrollHeight,
behavior: "smooth"
});
}
function isNearBottom() {
var threshold = 100;
var scrollPos = window.scrollY + window.innerHeight;
var docHeight = document.body.scrollHeight;
return docHeight - scrollPos < threshold;
}
var lastScrollY = 0;
window.addEventListener("scroll", function () {
var currentScrollY = window.scrollY;
if (currentScrollY < lastScrollY && !isNearBottom()) {
userScrolledUp = true;
} else if (isNearBottom()) {
userScrolledUp = false;
}
lastScrollY = currentScrollY;
}, { passive: true });
function getStepAncestor(node) {
var el = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
while (el && el !== document.body) {
if (el.classList && el.classList.contains("step")) return el;
el = el.parentElement;
}
return null;
}
function getReplyContext(node) {
var el = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
var replyEq = null;
var chatMsg = null;
while (el && el !== document.body) {
if (el.classList && el.classList.contains("reply-equation")) replyEq = el;
if (el.classList && el.classList.contains("cb-chat-msg") && el.classList.contains("assistant")) chatMsg = el;
el = el.parentElement;
}
if (!chatMsg) return null;
var userQuestion = "";
var prev = chatMsg.previousElementSibling;
while (prev) {
if (prev.classList && prev.classList.contains("cb-chat-msg") && prev.classList.contains("user")) {
var textDiv = prev.querySelector("div:not(.cb-chat-context):not(.cb-chat-time)");
userQuestion = textDiv ? textDiv.textContent.trim() : prev.textContent.trim();
break;
}
prev = prev.previousElementSibling;
}
return {
latex: replyEq ? (replyEq.getAttribute("data-latex") || "") : "",
eqNum: replyEq ? (replyEq.getAttribute("data-eq-num") || "") : "",
userQuestion: userQuestion
};
}
function handleSelectionChange() {
var sel = window.getSelection();
if (!sel || sel.isCollapsed || !sel.rangeCount) {
hideSendBtn();
return;
}
var range = sel.getRangeAt(0);
var stepEl = getStepAncestor(range.startContainer);
if (!stepEl) {
hideSendBtn();
return;
}
var replyCtx = getReplyContext(range.startContainer);
showSendBtn(range, stepEl, replyCtx);
}
var askBtn = document.createElement("button");
askBtn.className = "cb-ask-btn hidden";
askBtn.textContent = "? Ask about this";
askBtn.style.position = "fixed";
askBtn.style.zIndex = "100";
askBtn.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.4)";
document.body.appendChild(askBtn);
function showSendBtn(range, stepEl, replyCtx) {
var rect = range.getBoundingClientRect();
sendBtn.classList.remove("hidden");
if (replyCtx) {
askBtn.classList.add("hidden");
} else {
askBtn.classList.remove("hidden");
}
var sendWidth = sendBtn.offsetWidth;
var askWidth = replyCtx ? 0 : askBtn.offsetWidth;
var gap = replyCtx ? 0 : 6;
var totalWidth = sendWidth + gap + askWidth;
var left = rect.left + rect.width / 2 - totalWidth / 2;
var top = rect.top - 40;
if (left < 8) left = 8;
if (left + totalWidth > window.innerWidth - 8) {
left = window.innerWidth - totalWidth - 8;
}
if (top < 8) top = rect.bottom + 8;
sendBtn.style.left = left + "px";
sendBtn.style.top = top + "px";
if (!replyCtx) {
askBtn.style.left = (left + sendWidth + 6) + "px";
askBtn.style.top = top + "px";
}
sendBtn._stepEl = stepEl;
sendBtn._replyCtx = replyCtx || null;
askBtn._stepEl = stepEl;
}
function hideSendBtn() {
sendBtn.classList.add("hidden");
sendBtn._stepEl = null;
sendBtn._replyCtx = null;
askBtn.classList.add("hidden");
askBtn._stepEl = null;
}
document.addEventListener("mouseup", function () {
setTimeout(handleSelectionChange, 10);
});
document.addEventListener("selectionchange", function () {
var sel = window.getSelection();
if (!sel || sel.isCollapsed) {
hideSendBtn();
}
});
sendBtn.addEventListener("mousedown", function (e) {
e.preventDefault();
});
sendBtn.addEventListener("click", function (e) {
e.preventDefault();
e.stopPropagation();
var stepEl = sendBtn._stepEl;
if (!stepEl) return;
var stepId = stepEl.getAttribute("data-step-id") || "?";
var stepTitle = stepEl.getAttribute("data-step-title") || "";
var replyCtx = sendBtn._replyCtx;
var latex = replyCtx && replyCtx.latex
? replyCtx.latex
: (stepEl.getAttribute("data-latex") || "");
var sel = window.getSelection();
var selectedText = sel ? sel.toString().trim() : "";
if (sel) sel.removeAllRanges();
hideSendBtn();
var payload = {
step_id: parseInt(stepId, 10) || 0,
title: stepTitle,
latex: latex,
text: selectedText
};
if (replyCtx) {
if (replyCtx.userQuestion) payload.reply_context = replyCtx.userQuestion;
if (replyCtx.eqNum) payload.eq_num = replyCtx.eqNum;
}
fetch("/select", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}).then(function (res) {
return res.json();
}).then(function (data) {
var clipText = data.formatted || ("[Step " + stepId + "] " + selectedText);
var chatInput = document.querySelector('.cb-chat-input[data-step="' + stepId + '"]');
if (chatInput) {
openChats[stepId] = true;
var thread = document.querySelector('.cb-chat-thread[data-step="' + stepId + '"]');
if (thread) thread.classList.add("open");
var before = chatInput.value.substring(0, chatInput.selectionStart || chatInput.value.length);
var after = chatInput.value.substring(chatInput.selectionEnd || chatInput.value.length);
chatInput.value = before + clipText + " " + after;
chatInputDrafts[stepId] = chatInput.value;
chatInput.focus();
var cursorPos = before.length + clipText.length + 1;
chatInput.setSelectionRange(cursorPos, cursorPos);
}
showToast("\u2192 chat input");
}).catch(function () {
var clipText = "[Step " + stepId + "] " + selectedText;
copyToClipboard(clipText).then(function () {
showToast("Step " + stepId + " \u2192 clipboard");
});
});
});
askBtn.addEventListener("mousedown", function (e) {
e.preventDefault();
});
askBtn.addEventListener("click", function (e) {
e.preventDefault();
e.stopPropagation();
var stepEl = askBtn._stepEl;
if (!stepEl) return;
var stepId = stepEl.getAttribute("data-step-id") || "1";
var stepTitle = stepEl.getAttribute("data-step-title") || "";
var latex = stepEl.getAttribute("data-latex") || "";
var sel = window.getSelection();
var selectedText = sel ? sel.toString().trim() : "";
if (sel) sel.removeAllRanges();
hideSendBtn();
selectionContext = {
selected: selectedText,
latex: latex,
step_title: stepTitle
};
openChats[stepId] = true;
var thread = document.querySelector('.cb-chat-thread[data-step="' + stepId + '"]');
if (thread) {
thread.classList.add("open");
var input = thread.querySelector(".cb-chat-input");
if (input) {
input.value = "What is " + selectedText + "?";
chatInputDrafts[stepId] = input.value;
input.focus();
}
}
});
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
return new Promise(function (resolve, reject) {
var ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
resolve();
} catch (err) {
reject(err);
}
document.body.removeChild(ta);
});
}
var chatToggleSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
function injectChatUI() {
var steps = boardContent.querySelectorAll(".step");
for (var i = 0; i < steps.length; i++) {
var stepEl = steps[i];
var stepId = stepEl.getAttribute("data-step-id");
if (!stepId) continue;
var header = stepEl.querySelector(".step-header");
if (header && !header.querySelector(".cb-chat-toggle")) {
var toggle = document.createElement("button");
toggle.className = "cb-chat-toggle";
toggle.setAttribute("data-step", stepId);
toggle.title = "Chat about this step";
toggle.innerHTML = chatToggleSvg + '<span class="cb-chat-badge" style="display:none">0</span>';
header.appendChild(toggle);
}
if (!stepEl.querySelector(".cb-chat-thread")) {
var thread = document.createElement("div");
thread.className = "cb-chat-thread";
thread.setAttribute("data-step", stepId);
thread.innerHTML =
'<div class="cb-chat-messages"></div>' +
'<div class="cb-chat-input-row">' +
'<input class="cb-chat-input" data-step="' + stepId + '" placeholder="Ask about this step..." />' +
'<button class="cb-chat-send" data-step="' + stepId + '">Send</button>' +
'</div>';
stepEl.appendChild(thread);
}
}
renderChatMessages();
}
function renderChatMessages() {
var threads = document.querySelectorAll(".cb-chat-thread");
for (var t = 0; t < threads.length; t++) {
var thread = threads[t];
var stepId = parseInt(thread.getAttribute("data-step"), 10);
var msgs = chatMessages.filter(function (m) { return m.step_id === stepId; });
var container = thread.querySelector(".cb-chat-messages");
var toggle = document.querySelector('.cb-chat-toggle[data-step="' + stepId + '"]');
var badge = toggle ? toggle.querySelector(".cb-chat-badge") : null;
if (badge) {
if (msgs.length > 0) {
badge.textContent = msgs.length;
badge.style.display = "flex";
} else {
badge.style.display = "none";
}
}
container.innerHTML = msgs.map(function (m) {
var contextHtml = m.context && m.context.selected
? '<div class="cb-chat-context">Re: "' + escapeHtml(m.context.selected) + '"' +
(m.context.step_title ? " in " + escapeHtml(m.context.step_title) : "") + '</div>'
: "";
var bodyHtml = m.role === "assistant" && m.rendered
? m.rendered
: '<div>' + escapeHtml(m.text) + '</div>';
return '<div class="cb-chat-msg ' + escapeHtml(m.role) + '">' +
contextHtml +
bodyHtml +
'<div class="cb-chat-time">' + new Date(m.timestamp).toLocaleTimeString() + '</div>' +
'</div>';
}).join("");
if (msgs.length > 0) container.scrollTop = container.scrollHeight;
}
Object.keys(openChats).forEach(function (stepId) {
if (openChats[stepId]) {
var thread = document.querySelector('.cb-chat-thread[data-step="' + stepId + '"]');
if (thread) thread.classList.add("open");
}
});
Object.keys(chatInputDrafts).forEach(function (stepId) {
var input = document.querySelector('.cb-chat-input[data-step="' + stepId + '"]');
if (input) input.value = chatInputDrafts[stepId];
});
}
function escapeHtml(str) {
if (!str) return "";
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
var chatPollTimer = null;
var chatPollCount = 0;
async function sendChatMessage(stepId, text, context) {
try {
var body = { step_id: stepId, text: text };
if (context) body.context = context;
var resp = await fetch("/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (resp.ok) {
showToast("Question sent for step " + stepId);
startChatPoll();
}
} catch (err) {
console.error("Chat send error:", err);
}
}
function startChatPoll() {
stopChatPoll();
chatPollCount = 0;
chatPollTimer = setInterval(function () {
chatPollCount++;
fetchChat();
if (chatPollCount >= 15) stopChatPoll(); }, 2000);
}
function stopChatPoll() {
if (chatPollTimer) {
clearInterval(chatPollTimer);
chatPollTimer = null;
}
}
async function fetchChat() {
try {
var resp = await fetch("/chat");
if (resp.ok) {
var data = await resp.json();
var newCount = (data.messages || []).length;
if (newCount !== chatMessages.length) {
console.log("[poll] Got", newCount, "messages via GET /chat (was", chatMessages.length, ")");
}
chatMessages = data.messages || [];
renderChatMessages();
}
} catch (err) {
}
}
document.addEventListener("click", function (e) {
var toggle = e.target.closest(".cb-chat-toggle");
if (toggle) {
var stepId = toggle.getAttribute("data-step");
openChats[stepId] = !openChats[stepId];
var thread = document.querySelector('.cb-chat-thread[data-step="' + stepId + '"]');
if (thread) {
thread.classList.toggle("open");
if (thread.classList.contains("open")) {
var input = thread.querySelector(".cb-chat-input");
if (input) input.focus();
}
}
}
});
document.addEventListener("click", function (e) {
if (e.target.classList.contains("cb-chat-send")) {
var stepId = parseInt(e.target.getAttribute("data-step"), 10);
var input = document.querySelector('.cb-chat-input[data-step="' + stepId + '"]');
if (input && input.value.trim()) {
var context = selectionContext;
selectionContext = null;
sendChatMessage(stepId, input.value.trim(), context);
input.value = "";
delete chatInputDrafts[stepId];
}
}
});
document.addEventListener("keydown", function (e) {
if (e.target.classList.contains("cb-chat-input") && e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
var stepId = parseInt(e.target.getAttribute("data-step"), 10);
if (e.target.value.trim()) {
var context = selectionContext;
selectionContext = null;
sendChatMessage(stepId, e.target.value.trim(), context);
e.target.value = "";
delete chatInputDrafts[stepId];
}
}
});
document.addEventListener("input", function (e) {
if (e.target.classList.contains("cb-chat-input")) {
chatInputDrafts[e.target.getAttribute("data-step")] = e.target.value;
}
});
function showToast(msg) {
toast.classList.remove("hidden");
toast.textContent = msg;
toast.style.opacity = "1";
setTimeout(function () {
toast.style.opacity = "0";
}, 1500);
}
function setTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
try { localStorage.setItem("cliboard-theme", theme); } catch (e) {}
var buttons = document.querySelectorAll("#theme-toggle button");
for (var i = 0; i < buttons.length; i++) {
if (buttons[i].getAttribute("data-theme") === theme) {
buttons[i].classList.add("active");
} else {
buttons[i].classList.remove("active");
}
}
}
function initTheme() {
var saved = null;
try { saved = localStorage.getItem("cliboard-theme"); } catch (e) {}
setTheme(saved || "dark");
var buttons = document.querySelectorAll("#theme-toggle button");
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener("click", function () {
setTheme(this.getAttribute("data-theme"));
});
}
}
document.addEventListener("DOMContentLoaded", function () {
toast.classList.remove("hidden");
toast.style.opacity = "0";
initTheme();
poll();
fetchChat();
});
})();