import {messagesDiv, msgInput} from "./dom.js?v=20260909-account-controls";
import {appendAttachments} from "./attachments.js?v=20260930-chat-usability";
import {updateReasoning} from "./reasoning.js?v=20260921-reasoning-capability";
import {appendSources} from "./webSearch.js";
import {get} from "./api.js";
export function createMessageRenderer({
getChatById,
state,
setNotice,
sendMessage,
editChatMessage,
deleteChatMessage,
branchFromMessage,
retryLatestAssistantMessage,
switchResponseVersion,
switchRetryVariant,
renderDrawerSummary,
updateChatActionState,
updateComposerPlaceholder,
createNewChat,
openMessage
}) {
const jumpToLatest = document.getElementById("jumpToLatest");
const searchInput = document.getElementById("conversationSearch");
const searchCount = document.getElementById("conversationSearchCount");
const previousMatch = document.getElementById("conversationSearchPrevious");
const nextMatch = document.getElementById("conversationSearchNext");
const replyAnnouncement = document.getElementById("replyAnnouncement");
let scrollFrame = null;
let renderedChatId = null;
let renderedMessages = new Map();
let searchMatches = [];
let conversationMatches = null;
let selectedMatch = 0;
let searchChatId = null;
let searchFrame = null;
let searchTimer = null;
let searchRequest = 0;
let searchNavigation = 0;
function clearSearchMarks() {
messagesDiv.querySelectorAll("mark.conversation-search-match").forEach(mark => {
mark.replaceWith(document.createTextNode(mark.textContent));
});
messagesDiv.normalize();
}
function updateSearchCount() {
const total = conversationMatches?.length ?? searchMatches.length;
const label = total ? `${selectedMatch + 1} of ${total} matches` : "0 matches";
if (searchCount.textContent !== label) searchCount.textContent = label;
previousMatch.disabled = nextMatch.disabled = !total;
const target = conversationMatches?.[selectedMatch];
searchMatches.forEach((mark, index) => mark.classList.toggle("current", target
? String(target.messageId) === mark.dataset.messageId && Number(mark.dataset.occurrence) === target.occurrence
: index === selectedMatch));
}
function refreshConversationSearch() {
if (searchFrame !== null) { cancelAnimationFrame(searchFrame); searchFrame = null; }
clearSearchMarks();
searchMatches = [];
const query = searchInput.value.trim();
if (query) {
const needle = query.toLocaleLowerCase();
const occurrences = new Map();
messagesDiv.querySelectorAll(".msg-content").forEach(content => {
const messageId = content.closest(".msg")?.dataset.messageId || "";
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(node => {
const value = node.nodeValue;
const lower = value.toLocaleLowerCase();
let start = lower.indexOf(needle);
if (start < 0) return;
const fragment = document.createDocumentFragment();
let cursor = 0;
while (start >= 0) {
fragment.append(document.createTextNode(value.slice(cursor, start)));
const mark = document.createElement("mark");
mark.className = "conversation-search-match";
mark.textContent = value.slice(start, start + query.length);
mark.dataset.messageId = messageId;
mark.dataset.occurrence = String(occurrences.get(messageId) || 0);
occurrences.set(messageId, (occurrences.get(messageId) || 0) + 1);
fragment.append(mark);
searchMatches.push(mark);
cursor = start + query.length;
start = lower.indexOf(needle, cursor);
}
fragment.append(document.createTextNode(value.slice(cursor)));
node.replaceWith(fragment);
});
});
}
selectedMatch = Math.min(selectedMatch, Math.max(0, (conversationMatches?.length ?? searchMatches.length) - 1));
updateSearchCount();
}
function scheduleConversationSearch(reset = false, focusMatch = false) {
clearTimeout(searchTimer);
const request = ++searchRequest;
const chatId = state.activeChatId;
const query = searchInput.value.trim();
if (reset || !query) conversationMatches = null;
updateSearchCount();
if (!chatId || !query) return;
searchTimer = setTimeout(async () => {
const result = await get(`/chats/${chatId}/messages/search?q=${encodeURIComponent(query)}`);
if (request !== searchRequest || chatId !== state.activeChatId || query !== searchInput.value.trim()) return;
if (result.error) { searchCount.textContent = "Search unavailable"; return; }
conversationMatches = result.matches || [];
selectedMatch = Math.min(selectedMatch, Math.max(0, conversationMatches.length - 1));
updateSearchCount();
if (focusMatch && conversationMatches.length) void moveSearchMatch(0);
}, 200);
}
function scheduleSearchRefresh() {
if (!searchInput.value.trim() || searchFrame !== null) return;
searchFrame = requestAnimationFrame(refreshConversationSearch);
}
async function moveSearchMatch(direction) {
const navigation = ++searchNavigation;
const hits = conversationMatches || searchMatches;
if (!hits.length) return;
selectedMatch = (selectedMatch + direction + hits.length) % hits.length;
updateSearchCount();
const hit = conversationMatches?.[selectedMatch];
if (hit && !searchMatches.some(mark => mark.dataset.messageId === String(hit.messageId))) {
await openMessage(hit.messageId);
if (navigation !== searchNavigation || state.activeChatId !== searchChatId) return;
refreshConversationSearch();
}
const mark = hit
? searchMatches.find(item => item.dataset.messageId === String(hit.messageId) && Number(item.dataset.occurrence) === hit.occurrence)
|| searchMatches.find(item => item.dataset.messageId === String(hit.messageId))
: searchMatches[selectedMatch];
mark?.scrollIntoView({block: "center", behavior: "smooth"});
}
searchInput.addEventListener("input", () => {
searchNavigation++;
selectedMatch = 0;
conversationMatches = null;
refreshConversationSearch();
searchMatches[0]?.scrollIntoView({block: "center", behavior: "smooth"});
scheduleConversationSearch(false, true);
});
searchInput.addEventListener("keydown", event => {
if (event.key === "Enter") { event.preventDefault(); moveSearchMatch(event.shiftKey ? -1 : 1); }
});
previousMatch.addEventListener("click", () => moveSearchMatch(-1));
nextMatch.addEventListener("click", () => moveSearchMatch(1));
function announceResponding() { replyAnnouncement.textContent = "Assistant is responding."; }
function announceResponseStatus(status) { replyAnnouncement.textContent = status; }
function announceReply(content) {
const plain = document.createElement("div");
renderMarkdown(plain, content || "");
replyAnnouncement.textContent = `Assistant: ${plain.textContent.trim() || "Response complete."}`;
if (searchInput.value.trim()) scheduleConversationSearch();
}
function renderMarkdown(el, content) {
const markdownParser = window.marked;
const purifier = window.DOMPurify;
if (markdownParser?.parse && purifier?.sanitize) {
el.innerHTML = purifier.sanitize(markdownParser.parse(content, { breaks: true }));
} else {
el.textContent = content;
}
}
function setMessageContent(el, content) {
renderMarkdown(el, content);
scheduleSearchRefresh();
}
function scrollMessagesToBottom() {
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
scrollFrame = requestAnimationFrame(() => {
scrollFrame = null;
messagesDiv.scrollTop = messagesDiv.scrollHeight;
updateJumpToLatest();
});
}
function updateJumpToLatest() {
const distance = messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight;
jumpToLatest.classList.toggle("hidden", distance < 180);
}
function addMessage(contentOrMessage, isUser = false, isLoading = false, options = {}) {
const msgDiv = document.createElement("div");
msgDiv.className = `msg ${isUser ? "user" : "bot"}${isLoading ? " loading" : ""}`;
msgDiv.setAttribute("role", "article");
msgDiv.tabIndex = 0;
if (isLoading) {
msgDiv.setAttribute("aria-label", "Assistant is responding");
msgDiv.innerHTML = `
`;
} else {
const message = typeof contentOrMessage === "object" && contentOrMessage
? contentOrMessage
: { id: null, role: isUser ? "user" : "bot", content: String(contentOrMessage || "") };
const isMessageUser = message.role === "user";
msgDiv.dataset.messageId = message.id;
msgDiv.className = `msg ${isMessageUser ? "user" : "bot"}`;
msgDiv.setAttribute("aria-label", isMessageUser ? "Your message" : "Assistant message");
const header = document.createElement("div"), body = document.createElement("div"), actions = document.createElement("div");
header.className = "msg-header"; body.className = "msg-content";
actions.className = "msg-actions";
actions.setAttribute("aria-label", "Message actions");
const activeChat = getChatById(state.activeChatId);
header.textContent = isMessageUser ? "You" : (activeChat?.assistant_persona_name || "Assistant");
setMessageContent(body, message.content);
const copyBtn = document.createElement("button");
copyBtn.type = "button";
copyBtn.className = "msg-action-btn";
copyBtn.textContent = "Copy";
copyBtn.setAttribute("aria-label", `Copy ${isMessageUser ? "your" : "assistant"} message`);
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(message.content || "");
setNotice("Message copied.", "success");
} catch {
setNotice("Unable to copy message.", "error");
}
});
actions.appendChild(copyBtn);
if (["failed", "pending"].includes(message.deliveryStatus) && isMessageUser) {
const retryFailedBtn = document.createElement("button");
retryFailedBtn.type = "button";
retryFailedBtn.className = "msg-action-btn";
retryFailedBtn.dataset.messageAction = "retry-failed";
retryFailedBtn.textContent = message.deliveryStatus === "pending" ? "Resume" : "Retry";
retryFailedBtn.disabled = state.isProcessing;
retryFailedBtn.addEventListener("click", () => { void sendMessage({ retryMessage: message }); });
actions.appendChild(retryFailedBtn);
}
const menu = document.createElement("details");
menu.className = "msg-action-menu";
const menuTrigger = document.createElement("summary");
menuTrigger.className = "msg-action-btn";
menuTrigger.textContent = "More";
menuTrigger.setAttribute("aria-label", "More message actions");
const menuItems = document.createElement("div");
menuItems.className = "msg-action-menu-items";
menuItems.addEventListener("click", () => { menu.open = false; });
menu.append(menuTrigger, menuItems);
if (message.id) actions.appendChild(menu);
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "msg-action-btn";
editBtn.textContent = isMessageUser ? "Edit & resend" : "Edit";
editBtn.setAttribute("aria-label", `Edit ${isMessageUser ? "your" : "assistant"} message`);
editBtn.addEventListener("click", () => {
void editChatMessage(message.id, options.messageIndex ?? -1);
});
menuItems.appendChild(editBtn);
const deleteBtn = document.createElement("button");
deleteBtn.type = "button";
deleteBtn.className = "msg-action-btn";
deleteBtn.textContent = "Delete";
deleteBtn.setAttribute("aria-label", `Delete ${isMessageUser ? "your" : "assistant"} message`);
deleteBtn.addEventListener("click", () => {
void deleteChatMessage(message.id, options.messageIndex ?? -1);
});
menuItems.appendChild(deleteBtn);
const branchBtn = document.createElement("button");
branchBtn.type = "button";
branchBtn.className = "msg-action-btn";
branchBtn.textContent = "Branch here";
branchBtn.addEventListener("click", () => { void branchFromMessage(message.id); });
menuItems.appendChild(branchBtn);
if (message.role === "bot") {
const retriesUsed = Math.max(0, Number(message.versionCount || 1) - 1);
const retryBtn = document.createElement("button");
retryBtn.type = "button";
retryBtn.className = "msg-action-btn";
retryBtn.dataset.messageAction = "regenerate";
retryBtn.dataset.retryLimitReached = String(retriesUsed >= 5);
retryBtn.textContent = `Regenerate (${retriesUsed}/5)`;
retryBtn.setAttribute("aria-label", `Regenerate assistant message, ${retriesUsed} of 5 retries used`);
retryBtn.disabled = retriesUsed >= 5 || state.isProcessing;
retryBtn.addEventListener("click", () => {
void retryLatestAssistantMessage(message.id, options.messageIndex ?? -1);
});
actions.appendChild(retryBtn);
}
for (const versioned of [true, false]) {
if (Number((versioned ? message.versionCount : message.siblingCount) || 1) <= 1) continue;
const navigate = versioned ? switchResponseVersion : switchRetryVariant;
const prevBtn = document.createElement("button");
prevBtn.type = "button";
prevBtn.className = "msg-action-btn";
prevBtn.textContent = "Prev";
prevBtn.setAttribute("aria-label", versioned ? "Show previous response version" : "Show previous branch");
prevBtn.disabled = versioned ? message.versionIndex <= 0 : !message.previousSiblingId;
prevBtn.addEventListener("click", () => {
void navigate(message.id, -1, options.messageIndex ?? -1);
});
const nextBtn = document.createElement("button");
nextBtn.type = "button";
nextBtn.className = "msg-action-btn";
nextBtn.textContent = "Next";
nextBtn.setAttribute("aria-label", versioned ? "Show next response version" : "Show next branch");
nextBtn.disabled = versioned ? message.versionIndex >= message.versionCount - 1 : !message.nextSiblingId;
nextBtn.addEventListener("click", () => {
void navigate(message.id, 1, options.messageIndex ?? -1);
});
const indexBadge = document.createElement("span");
indexBadge.className = "msg-variant-index";
indexBadge.textContent = versioned ? `${message.versionIndex + 1} / ${message.versionCount}` : `${Number(message.siblingIndex || 0) + 1}/${message.siblingCount}`;
actions.append(prevBtn, indexBadge, nextBtn);
}
msgDiv.append(header);
appendAttachments(msgDiv, message.attachments);
msgDiv.append(body);
if (!isMessageUser) updateReasoning(msgDiv, message.thinking);
if (!isMessageUser) appendSources(msgDiv, message.sources);
if (message.deliveryStatus === "failed" && message.errorMessage) {
const error = document.createElement("p");
error.className = "status error";
error.textContent = message.errorMessage;
msgDiv.appendChild(error);
}
if (actions.childElementCount) {
msgDiv.appendChild(actions);
}
}
if (!options.detached) {
messagesDiv.appendChild(msgDiv);
scrollMessagesToBottom();
}
return msgDiv;
}
function renderMessages({preserveScroll = false} = {}) {
if (scrollFrame !== null) { cancelAnimationFrame(scrollFrame); scrollFrame = null; }
const sameChat = renderedChatId === state.activeChatId;
const wasReadingEarlier = sameChat && messagesDiv.scrollHeight - messagesDiv.scrollTop - messagesDiv.clientHeight >= 100;
const previousScrollTop = messagesDiv.scrollTop;
const chat = getChatById(state.activeChatId);
if (!state.currentMessages.length && (!chat || chat.assistant_persona_id)) {
messagesDiv.replaceChildren();
renderMessageEmptyState();
renderedMessages = new Map();
} else {
const fragment = document.createDocumentFragment();
const nextRendered = new Map();
state.currentMessages.forEach((msg, index) => {
const key = msg.id ? String(msg.id) : null;
const snapshot = JSON.stringify(msg);
const previous = sameChat && key ? renderedMessages.get(key) : null;
const card = previous?.snapshot === snapshot && previous.node.isConnected
? previous.node
: addMessage(msg, msg.role === "user", false, {
messageIndex: index,
isNewest: index === state.currentMessages.length - 1,
detached: true
});
fragment.append(card);
if (key) nextRendered.set(key, {snapshot, node: card});
});
messagesDiv.replaceChildren(fragment);
renderedMessages = nextRendered;
}
renderedChatId = state.activeChatId;
if (searchChatId !== state.activeChatId) {
searchNavigation++;
searchChatId = state.activeChatId;
selectedMatch = 0;
scheduleConversationSearch(true, true);
} else if (searchInput.value.trim()) scheduleConversationSearch();
refreshConversationSearch();
renderDrawerSummary();
updateChatActionState();
updateComposerPlaceholder();
if (wasReadingEarlier || preserveScroll) {
messagesDiv.scrollTop = previousScrollTop;
updateJumpToLatest();
} else if (state.currentMessages.length) {
scrollMessagesToBottom();
} else {
updateJumpToLatest();
}
}
function createStarterAction(label, className, handler) {
const button = document.createElement("button");
button.type = "button";
button.className = className;
button.textContent = label;
button.addEventListener("click", handler);
return button;
}
function createMessageStarterPrompt(label, prompt) {
return createStarterAction(label, "secondary-action", async () => {
if (!state.activeChatId) await createNewChat();
msgInput.value = prompt;
msgInput.dispatchEvent(new Event("input"));
msgInput.focus();
});
}
function renderMessageEmptyState() {
const empty = document.createElement("section");
const title = document.createElement("h3");
const description = document.createElement("p");
const actions = document.createElement("div");
const chat = getChatById(state.activeChatId);
empty.className = "messages-empty";
title.className = "messages-empty-title";
description.className = "messages-empty-copy";
actions.className = "messages-empty-actions";
if (!chat) {
title.textContent = "Where shall we start?";
description.textContent = "Choose a starting point.";
actions.append(
createStarterAction("New chat", "primary-action", () => { void createNewChat(); }),
createStarterAction("Browse Personalities", "secondary-action", () => { location.assign("/app/personas"); }),
createMessageStarterPrompt("Try a prompt", "Help me plan my day in 3 steps.")
);
} else if (chat.assistant_persona_id) {
title.textContent = "Roleplay is ready";
description.textContent = "Send a line or pick a prompt.";
actions.append(
createMessageStarterPrompt("Add tension", "Open with a tense line that raises the stakes immediately."),
createMessageStarterPrompt("Set the scene", "Describe what your character notices first in this scene.")
);
} else {
title.textContent = "What can I help with?";
description.textContent = "Write a message below or start with one of these prompts.";
actions.append(
createMessageStarterPrompt("Explain something", "Explain this topic in simple terms: "),
createMessageStarterPrompt("Plan a task", "Help me make a practical step-by-step plan for: "),
createMessageStarterPrompt("Brainstorm ideas", "Give me several creative ideas for: ")
);
}
empty.append(title, description, actions);
messagesDiv.appendChild(empty);
}
return {
setMessageContent,
announceResponding,
announceResponseStatus,
announceReply,
scrollMessagesToBottom,
updateJumpToLatest,
addMessage,
renderMessages
};
}