diff --git a/public/chat.html b/public/chat.html index fdf43c5..10d2a50 100644 --- a/public/chat.html +++ b/public/chat.html @@ -41,6 +41,8 @@ +
diff --git a/public/css/shell.css b/public/css/shell.css index 712da4d..76ebe60 100644 --- a/public/css/shell.css +++ b/public/css/shell.css @@ -33,6 +33,7 @@ body.site-page { width:100%; max-width:none; margin:0; padding:0; background:var .chat-page .chat-card { height:calc(100dvh - var(--shell-header-height)); min-height:0; border:0; border-radius:0; box-shadow:none; } .chat-page .chat-layout { grid-template-columns:260px minmax(0,1fr); } .chat-page .chat-sidebar { position:static; inset:auto; width:auto; transform:none; box-shadow:none; grid-template-rows:auto minmax(0,1fr); padding:24px 18px; gap:20px; } +.chat-page .chat-sidebar-backdrop { display:none; } .chat-page .sidebar-workspace-card { gap:16px; border:0; padding:0; } .chat-page .sidebar-workspace-card h2 { margin:0; font-size:.85rem; letter-spacing:0; font-family:var(--font-body); color:var(--text-soft); } .chat-page .chat-main { grid-template-rows:auto minmax(0,1fr) auto; } @@ -44,6 +45,7 @@ body.site-page { width:100%; max-width:none; margin:0; padding:0; background:var .chat-page .chat-layout { grid-template-columns:1fr; } .chat-page .chat-sidebar { position:fixed; inset:var(--shell-header-height) auto 0 0; width:min(320px,88vw); z-index:80; transform:translateX(-104%); box-shadow:var(--shadow-lg); } .chat-page.sidebar-open .chat-sidebar { transform:translateX(0); } + .chat-page .chat-sidebar-backdrop:not([hidden]) { display:block; position:fixed; inset:var(--shell-header-height) 0 0; z-index:79; width:100%; height:auto; padding:0; border:0; border-radius:0; background:rgba(0,0,0,.45); } .chat-page .sidebar-toggle { display:inline-flex; width:auto; min-width:0; padding:8px 12px; font-size:.8rem; border-radius:10px; } .site-page .settings-layout { grid-template-columns:1fr; } .site-page .settings-nav { display:flex; flex-wrap:wrap; } diff --git a/public/css/style.css b/public/css/style.css index 5bd75d0..2312130 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -419,6 +419,14 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); } .msg.user .msg-actions { right: 0; left: auto; justify-content: flex-end; } .msg:hover .msg-actions, .msg:focus-within .msg-actions { opacity: 1; } .msg-action-btn { min-height: 30px; padding: 5px 8px; color: var(--text-soft) !important; background: transparent !important; border: 1px solid var(--border); border-radius: 8px; font-size: .7rem; } +.msg-action-menu { position: relative; } +.msg-action-menu > summary { display: flex; align-items: center; cursor: pointer; list-style: none; } +.msg-action-menu > summary::-webkit-details-marker { display: none; } +.msg-action-menu-items { position: absolute; bottom: calc(100% + 4px); left: 0; min-width: 130px; padding: 5px; display: grid; gap: 3px; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 9px; box-shadow: var(--shadow-lg); } +.msg.user .msg-action-menu-items { left: auto; right: 0; } +.msg-action-menu-items .msg-action-btn { width: 100%; text-align: left; border: 0; } +.msg-action-menu-items .msg-action-btn:hover, .msg-action-menu-items .msg-action-btn:focus-visible { background: var(--surface-hover) !important; } +.msg-action-menu[open] { z-index: 5; } .msg-variant-index { align-self: center; color: var(--text-muted); font-size: .7rem; } .typing { display: flex; gap: 5px; } .typing-dot { width: 6px; height: 6px; background: var(--brand); border-radius: 50%; animation: typing 1.2s infinite; } diff --git a/public/js/script.js b/public/js/script.js index 21fb949..0ca4395 100644 --- a/public/js/script.js +++ b/public/js/script.js @@ -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();