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 = `
Assistant
Thinking
`; } 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({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; 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("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 }; }