make some overall QoL improvements
This commit is contained in:
@@ -2,6 +2,7 @@ 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,
|
||||
@@ -17,17 +18,157 @@ export function createMessageRenderer({
|
||||
renderDrawerSummary,
|
||||
updateChatActionState,
|
||||
updateComposerPlaceholder,
|
||||
createNewChat
|
||||
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 setMessageContent(el, content) {
|
||||
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) {
|
||||
@@ -37,6 +178,11 @@ export function createMessageRenderer({
|
||||
}
|
||||
}
|
||||
|
||||
function setMessageContent(el, content) {
|
||||
renderMarkdown(el, content);
|
||||
scheduleSearchRefresh();
|
||||
}
|
||||
|
||||
function scrollMessagesToBottom() {
|
||||
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
|
||||
scrollFrame = requestAnimationFrame(() => {
|
||||
@@ -60,7 +206,7 @@ export function createMessageRenderer({
|
||||
msgDiv.setAttribute("aria-label", "Assistant is responding");
|
||||
msgDiv.innerHTML = `
|
||||
<div class="msg-header">Assistant</div>
|
||||
<div class="loading-shell" aria-live="polite" aria-label="Assistant is responding">
|
||||
<div class="loading-shell" aria-hidden="true">
|
||||
<div class="loading-bars">
|
||||
<span class="loading-bar"></span>
|
||||
<span class="loading-bar"></span>
|
||||
@@ -251,6 +397,13 @@ export function createMessageRenderer({
|
||||
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();
|
||||
@@ -325,6 +478,9 @@ export function createMessageRenderer({
|
||||
|
||||
return {
|
||||
setMessageContent,
|
||||
announceResponding,
|
||||
announceResponseStatus,
|
||||
announceReply,
|
||||
scrollMessagesToBottom,
|
||||
updateJumpToLatest,
|
||||
addMessage,
|
||||
|
||||
Reference in New Issue
Block a user