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";
export function createMessageRenderer({
getChatById,
state,
setNotice,
sendMessage,
editChatMessage,
deleteChatMessage,
branchFromMessage,
retryLatestAssistantMessage,
switchResponseVersion,
switchRetryVariant,
renderDrawerSummary,
updateChatActionState,
updateComposerPlaceholder,
createNewChat,
openRoleplayStarter,
openPersonaForm
}) {
const jumpToLatest = document.getElementById("jumpToLatest");
let scrollFrame = null;
let renderedChatId = null;
let renderedMessages = new Map();
function setMessageContent(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 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 (message.deliveryStatus === "failed" && isMessageUser) {
const retryFailedBtn = document.createElement("button");
retryFailedBtn.type = "button";
retryFailedBtn.className = "msg-action-btn";
retryFailedBtn.dataset.messageAction = "retry-failed";
retryFailedBtn.textContent = "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() {
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;
renderDrawerSummary();
updateChatActionState();
updateComposerPlaceholder();
if (wasReadingEarlier) {
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("Roleplay", "secondary-action", () => {
if (state.assistantPersonas.length) {
openRoleplayStarter();
return;
}
openPersonaForm(null, "assistant");
}),
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."),
createStarterAction("Change setup", "secondary-action", () => openRoleplayStarter())
);
} 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,
scrollMessagesToBottom,
updateJumpToLatest,
addMessage,
renderMessages
};
}