make some overall QoL improvements

This commit is contained in:
Frederik Spirgi
2026-09-30 09:35:06 +02:00
parent 0a24035b27
commit 7a1b152f20
4 changed files with 193 additions and 40 deletions
+181 -40
View File
@@ -127,6 +127,10 @@ let chatLoadingDepth = 0;
let chatActivityDepth = 0;
let messageRetryState = new Map();
let availableModelsById = new Map();
let chatSearchResults = null;
let chatSearchError = "";
let chatSearchTimer = null;
let chatSearchRequest = 0;
function setNotice(message = "", state = "") {
@@ -193,15 +197,33 @@ function isMobileLayout() {
}
function closeMobileSidebar() {
if (!chatSidebar) return;
if (!chatSidebar || !document.body.classList.contains("sidebar-open")) return;
document.body.classList.remove("sidebar-open");
if (chatSidebarToggle) chatSidebarToggle.setAttribute("aria-expanded", "false");
if (chatSidebarToggle) {
chatSidebarToggle.setAttribute("aria-expanded", "false");
chatSidebarToggle.setAttribute("aria-label", "Open conversation history");
}
chatSidebar.inert = isMobileLayout();
chatSidebar.removeAttribute("aria-modal");
chatSidebar.removeAttribute("role");
const backdrop = document.getElementById("chatSidebarBackdrop");
if (backdrop) backdrop.hidden = true;
chatSidebarToggle?.focus();
}
function openMobileSidebar() {
if (!chatSidebar) return;
if (!chatSidebar || !isMobileLayout()) return;
chatSidebar.inert = false;
chatSidebar.setAttribute("role", "dialog");
chatSidebar.setAttribute("aria-modal", "true");
document.body.classList.add("sidebar-open");
if (chatSidebarToggle) chatSidebarToggle.setAttribute("aria-expanded", "true");
if (chatSidebarToggle) {
chatSidebarToggle.setAttribute("aria-expanded", "true");
chatSidebarToggle.setAttribute("aria-label", "Close conversation history");
}
const backdrop = document.getElementById("chatSidebarBackdrop");
if (backdrop) backdrop.hidden = false;
chatSearchInput?.focus();
}
function hasCompletedOnboarding() {
@@ -477,7 +499,10 @@ function ensureRetryState(message) {
async function openCreatedChat(chat) {
if (!chat?.id) throw new Error("The server did not return the newly created chat");
if (chatSearchInput) chatSearchInput.value = "";
if (chatSearchInput) {
chatSearchInput.value = "";
scheduleChatSearch();
}
const chatsResponse = await get("/chats");
if (chatsResponse.error) throw new Error(chatsResponse.error);
chatSessions = chatsResponse.chats || [];
@@ -705,6 +730,44 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
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 = 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";
@@ -713,7 +776,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
editBtn.addEventListener("click", () => {
void editChatMessage(message.id, options.messageIndex ?? -1);
});
actions.appendChild(editBtn);
menuItems.appendChild(editBtn);
const deleteBtn = document.createElement("button");
deleteBtn.type = "button";
@@ -723,14 +786,14 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
deleteBtn.addEventListener("click", () => {
void deleteChatMessage(message.id, options.messageIndex ?? -1);
});
actions.appendChild(deleteBtn);
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); });
actions.appendChild(branchBtn);
menuItems.appendChild(branchBtn);
if (message.role === "bot") {
const retriesUsed = Math.max(0, Number(message.versionCount || 1) - 1);
@@ -784,7 +847,7 @@ function addMessage(contentOrMessage, isUser = false, isLoading = false, options
if (!isMessageUser) appendSources(msgDiv, message.sources);
if (message.deliveryStatus === "failed" && message.errorMessage) {
const error = document.createElement("p");
error.className = "status";
error.className = "status error";
error.textContent = message.errorMessage;
msgDiv.appendChild(error);
}
@@ -1049,14 +1112,17 @@ function maybeShowPersonaNudge() {
function renderChatList() {
const query = chatSearchInput.value.trim().toLowerCase();
const filtered = chatSessions.filter((chat) => !query
const source = query ? (chatSearchResults ?? []) : chatSessions;
const filtered = source.filter((chat) => !query
|| chat.title.toLowerCase().includes(query)
|| String(chat.matched_content || "").toLowerCase().includes(query));
chatList.innerHTML = "";
if (!filtered.length) {
const empty = document.createElement("p");
empty.className = "status";
empty.textContent = query ? "No chats match this search." : "No chats yet. Start a new one.";
empty.textContent = query && chatSearchError ? chatSearchError
: query && chatSearchResults === null ? "Searching chats..."
: query ? "No chats match this search." : "No chats yet. Start a new one.";
chatList.appendChild(empty);
return;
}
@@ -1105,6 +1171,7 @@ function renderChatList() {
remove.setAttribute("aria-label", `Delete chat ${chat.title}`);
remove.addEventListener("click", async (event) => {
event.stopPropagation();
closeMobileSidebar();
await deleteChat(chat.id);
});
content.append(title, meta);
@@ -1408,6 +1475,9 @@ function setLoadingState(loading, overlayOptions = null) {
messagesDiv.querySelectorAll('[data-message-action="regenerate"]').forEach((button) => {
button.disabled = loading || button.dataset.retryLimitReached === "true";
});
messagesDiv.querySelectorAll('[data-message-action="retry-failed"]').forEach((button) => {
button.disabled = loading;
});
if (loading) {
msgInput.placeholder = "Processing response...";
if (overlayOptions === false) {
@@ -1460,13 +1530,17 @@ async function setActiveChat(id, {preserveComposer = false} = {}) {
async function loadChatSessions() {
setChatLoading(true, "Loading chats...");
const query = chatSearchInput?.value.trim();
const res = query ? await get(`/chats/search?q=${encodeURIComponent(query)}`) : await get("/chats");
const res = await get("/chats");
if (res.error) {
setChatLoading(false);
return setNotice(res.error, "error");
}
chatSessions = res.chats || []; renderChatList();
if (activeChatId && getChatById(activeChatId)) {
updateWorkspaceCopy();
setChatLoading(false);
return;
}
if (location.pathname.endsWith("/new") && !activeChatId) { currentMessages = []; renderMessages(); updateWorkspaceCopy(); updateSendState(); setChatLoading(false); return; }
if (requestedChatId && !getChatById(requestedChatId) && !activeChatId) { setChatLoading(false); return setNotice("Conversation not found. Choose a chat from the sidebar or start a new one.", "error"); }
if (chatSessions.length) {
@@ -1483,6 +1557,33 @@ async function loadChatSessions() {
return setNotice("Pick a starting action to begin.", "success");
}
async function searchChatSessions(query, requestId) {
const res = await get(`/chats/search?q=${encodeURIComponent(query)}`);
if (requestId !== chatSearchRequest || chatSearchInput.value.trim() !== query) return;
if (res.error) {
chatSearchError = "Unable to search chats.";
renderChatList();
setNotice(res.error, "error");
return;
}
chatSearchResults = res.chats || [];
renderChatList();
}
function scheduleChatSearch() {
clearTimeout(chatSearchTimer);
const requestId = ++chatSearchRequest;
const query = chatSearchInput.value.trim();
chatSearchResults = null;
chatSearchError = "";
if (!query) {
renderChatList();
return;
}
renderChatList();
chatSearchTimer = setTimeout(() => { void searchChatSessions(query, requestId); }, 300);
}
async function createNewChat({preserveComposer = false} = {}) {
if (!preserveComposer) attachmentComposer.discard();
setChatLoading(true, "Creating chat...");
@@ -1852,12 +1953,16 @@ async function checkSession() {
msgInput.focus(); return true;
}
async function sendMessage() {
async function sendMessage({ retryMessage = null } = {}) {
if (isProcessing) return;
if (attachmentComposer.pending) return;
const attachments = attachmentComposer.attachments;
const message = msgInput.value.trim() || (attachments.length ? "Describe the attached images." : "");
const attachments = retryMessage?.attachments || attachmentComposer.attachments;
const message = retryMessage?.content || msgInput.value.trim() || (attachments.length ? "Describe the attached images." : "");
if (!message) return;
if (retryMessage && !msgInput.value.trim()) {
msgInput.value = message;
resizeComposerInput();
}
const webSearch = webSearchToggle.enabled;
const reasoning = reasoningToggle.enabled;
if (!activeChatId) await createNewChat({preserveComposer: true});
@@ -1868,25 +1973,31 @@ async function sendMessage() {
currentChat.title = autoTitle; updateWorkspaceCopy(); renderChatList(); await put(`/chats/${activeChatId}`, { title: autoTitle });
}
const userMessage = { role: "user", content: message, attachments };
currentMessages.push(userMessage); addMessage(userMessage, true); updateChatActionState();
msgInput.value = "";
resizeComposerInput();
currentMessages.push(userMessage);
const userCard = addMessage(userMessage, true);
updateChatActionState();
setLoadingState(true, false); setNotice("Generating reply...");
const loadingMsg = addMessage({ role: "bot", content: "" }, false, true, { isNewest: true });
const loadingBody = loadingMsg.querySelector(".msg-content");
const loadingShell = loadingMsg.querySelector(".loading-shell");
const loadingLabel = loadingMsg.querySelector(".loading-copy strong");
loadingLabel.textContent = "Preparing response...";
const showSendFailure = (errorMessage) => {
if (!msgInput.value.trim()) {
msgInput.value = message;
resizeComposerInput();
}
userMessage.deliveryStatus = "failed";
userMessage.errorMessage = errorMessage;
userCard.replaceWith(addMessage(userMessage, true, false, { detached: true }));
setNotice(errorMessage, "error");
};
let completedReply = null;
try {
let streamError = null;
await stream("/chat/stream", { message, attachments: attachments.map(item => item.id), model: modelSelect.value, chatId: activeChatId, webSearch, reasoning }, {
onStatus: ({ status, sourceCount, searchState }) => {
if (status === "accepted") {
attachmentComposer.clear();
webSearchToggle.reset();
reasoningToggle.reset();
return;
}
if (status === "accepted") return;
loadingLabel.textContent = status === "analyzing" ? "Analyzing images..." : status === "searching" ? "Searching the web..."
: searchState === "no_relevant_results" ? "No sufficiently relevant results. Generating response..."
: sourceCount === 0 ? "No web results found. Generating response..." : "Generating response...";
@@ -1909,7 +2020,10 @@ async function sendMessage() {
}
if (follow) scrollMessagesToBottom();
},
onDone: () => { webSearchToggle.reset(); reasoningToggle.reset(); finishReasoning(loadingMsg); },
onDone: ({ reply }) => {
completedReply = reply;
webSearchToggle.reset(); reasoningToggle.reset(); finishReasoning(loadingMsg);
},
onError: ({ error }) => {
streamError = error || "Failed to generate reply.";
}
@@ -1918,14 +2032,16 @@ async function sendMessage() {
const previousScrollTop = messagesDiv.scrollTop;
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
if (streamError) {
if (webSearch) {
msgInput.value = message;
resizeComposerInput();
}
addMessage(`[Error: ${streamError}]`);
setNotice(streamError, "error");
showSendFailure(streamError);
return;
}
if (msgInput.value.trim() === message) {
msgInput.value = "";
resizeComposerInput();
}
const composerAttachments = attachmentComposer.attachments;
if (composerAttachments.length === attachments.length
&& composerAttachments.every((item, index) => item.id === attachments[index].id)) attachmentComposer.clear();
await setActiveChat(activeChatId);
await loadSummary();
markOnboardingCompleted();
@@ -1934,12 +2050,16 @@ async function sendMessage() {
setNotice("Reply received.", "success");
} catch (error) {
if (messagesDiv.contains(loadingMsg)) messagesDiv.removeChild(loadingMsg);
if (webSearch) {
msgInput.value = message;
resizeComposerInput();
}
const errorMessage = error?.message || "Network error while sending message.";
addMessage(`[Error: ${errorMessage}]`); setNotice(errorMessage, "error");
if (completedReply !== null) {
if (userCard.isConnected) {
currentMessages.push({ role: "bot", content: completedReply });
addMessage(completedReply);
}
setNotice(`Reply sent, but the conversation could not be refreshed: ${errorMessage}`, "error");
} else {
showSendFailure(errorMessage);
}
} finally {
setLoadingState(false); msgInput.focus(); updateChatActionState();
}
@@ -1996,7 +2116,7 @@ function bindChatDrawerActions() {
}
sendBtn.addEventListener("click", () => { void (isProcessing ? stopGeneration() : sendMessage()); });
msgInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void sendMessage(); } });
msgInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); void sendMessage(); } });
msgInput.addEventListener("input", function () {
resizeComposerInput();
updateSendState();
@@ -2019,7 +2139,7 @@ if (importWorkspaceBtn) importWorkspaceBtn.addEventListener("click", () => {
if (importWorkspaceFileInput) importWorkspaceFileInput.addEventListener("change", () => {
void importWorkspaceFromFile(importWorkspaceFileInput.files?.[0]);
});
chatSearchInput.addEventListener("input", () => { void loadChatSessions(); });
chatSearchInput.addEventListener("input", scheduleChatSearch);
modelSelect.addEventListener("change", () => {
renderModelSection();
void saveSelectedModel();
@@ -2072,9 +2192,11 @@ popupInput.addEventListener("keydown", (event) => {
}
});
bindChatDrawerActions();
if (chatSidebar) chatSidebar.inert = isMobileLayout();
if (chatSidebar) {
chatSidebar.addEventListener("click", (event) => event.stopPropagation());
}
document.getElementById("chatSidebarBackdrop")?.addEventListener("click", closeMobileSidebar);
if (chatSidebarToggle) {
chatSidebarToggle.addEventListener("click", (event) => {
event.stopPropagation();
@@ -2105,14 +2227,33 @@ if (onboardingModal) {
if (event.target === onboardingModal) markOnboardingCompleted();
});
}
document.addEventListener("click", () => {
document.addEventListener("click", (event) => {
if (!event.target.closest(".msg-action-menu")) {
document.querySelectorAll(".msg-action-menu[open]").forEach((menu) => { menu.open = false; });
}
closeMobileSidebar();
});
window.addEventListener("resize", () => {
if (!isMobileLayout()) closeMobileSidebar();
if (chatSidebar) chatSidebar.inert = isMobileLayout() && !document.body.classList.contains("sidebar-open");
});
document.addEventListener("keydown", (event) => {
if (event.key === "Tab" && isMobileLayout() && document.body.classList.contains("sidebar-open")) {
const focusables = [...chatSidebar.querySelectorAll('button:not([disabled]), input:not([disabled]), [tabindex="0"]')]
.filter((element) => !element.hidden && !element.closest("[hidden]"));
const first = focusables[0], last = focusables.at(-1);
if (event.shiftKey && (document.activeElement === first || !chatSidebar.contains(document.activeElement))) {
event.preventDefault(); last?.focus();
} else if (!event.shiftKey && (document.activeElement === last || !chatSidebar.contains(document.activeElement))) {
event.preventDefault(); first?.focus();
}
}
if (event.key === "Escape") {
const openMenu = document.querySelector(".msg-action-menu[open]");
if (openMenu) {
openMenu.open = false;
openMenu.querySelector("summary")?.focus();
}
closeMobileSidebar();
closeRoleplayStarter();
closePersonaForm();