diff --git a/public/chat.html b/public/chat.html index 2e95a44..4ec5e70 100644 --- a/public/chat.html +++ b/public/chat.html @@ -11,7 +11,7 @@ href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"> - + @@ -128,9 +128,11 @@

Pick a model to see what it is best suited for.

-
- Advanced generation settings -
+
+
+
+ Generation settingsAdvanced +
@@ -147,11 +149,9 @@
-
-
- +
+
+ +
+ Workspace filesShow +
@@ -197,7 +202,7 @@
- Clear conversation + Delete chat content Show @@ -234,14 +239,21 @@
-
+ +
+
-

Enter to send · Shift+Enter for new line

+

Enter to send · Shift+Enter for new line

@@ -254,7 +266,7 @@ - + Attach +
@@ -439,7 +452,7 @@ integrity="sha512-rCQgmUulW6f6QegOvTntKKb5IAoxTpGVCdWqYjkXEpzAns6XUFs8NKVqWe+KQpctp/EoRSFSuykVputqknLYMg==" crossorigin="anonymous" defer> - + diff --git a/public/css/style.css b/public/css/style.css index 0c54264..5acdf2b 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -424,7 +424,14 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); } .chat-header-actions { display: flex; align-items: center; } .overflow-trigger { min-height: 38px; padding: 8px 12px; border-radius: 999px; font-size: .8rem; } -.messages-wrap { min-height: 0; position: relative; display: grid; grid-template-rows: auto minmax(0, 1fr); } +.messages-wrap { min-height: 0; position: relative; display: grid; grid-template-rows: auto auto minmax(0, 1fr); } +.conversation-search { padding: 8px max(16px, calc((100% - 940px) / 2)); display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--border); } +.conversation-search label { flex: 0 0 auto; color: var(--text-soft); font-size: .76rem; font-weight: 700; } +.conversation-search input { min-width: 0; width: min(280px, 100%); min-height: 34px; } +.conversation-search span { min-width: 64px; color: var(--text-muted); font-size: .72rem; white-space: nowrap; } +.conversation-search button { min-height: 34px; padding: 5px 9px; font-size: .72rem; } +.conversation-search-match { background: #facc15; color: #1f2937; border-radius: 2px; } +.conversation-search-match.current { outline: 2px solid var(--brand); outline-offset: 1px; } .messages-panel { height: 100%; min-height: 0; @@ -497,7 +504,10 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); } .input-meta, .input-container { width: min(940px, 100%); margin-inline: auto; } .input-meta { margin-bottom: 5px; padding-left: 10px; } .composer-hint { margin: 0; color: var(--text-muted); font-size: .72rem; } -.input-container { padding: 7px; display: grid; grid-template-columns: 44px 44px 44px minmax(0, 1fr) 44px; grid-template-areas: "attach search reasoning message send"; gap: 7px; align-items: end; background: var(--input-bg); border: 1px solid var(--border-strong); border-radius: 24px; box-shadow: var(--shadow-sm); } +.input-container { padding: 7px; display: grid; grid-template-columns: 44px 44px 44px minmax(0, 1fr) 44px 58px; grid-template-areas: "attach search reasoning message send stop"; gap: 7px; align-items: end; background: var(--input-bg); border: 1px solid var(--border-strong); border-radius: 24px; box-shadow: var(--shadow-sm); } +.input-container:not(:has(#stopGeneration:not([hidden]))) { grid-template-columns: 44px 44px 44px minmax(0, 1fr) 44px; grid-template-areas: "attach search reasoning message send"; } +.input-container [hidden] { display: none !important; } +.composer-tool-label { display: none; } #attachImage { grid-area: attach; } .upload-previews { width: min(940px, 100%); margin: 0 auto 8px; display: flex; gap: 8px; flex-wrap: wrap; } .upload-previews[hidden] { display: none; } @@ -508,10 +518,6 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); } .message-images img { max-width: min(360px, 100%); max-height: 300px; object-fit: contain; border-radius: 12px; } .message-images a { max-width: 100%; } .image-drag-target { outline: 2px solid currentColor; } -@media (max-width: 480px) { - .input-container { grid-template-columns: repeat(3, 36px) minmax(0, 1fr) 44px; grid-template-areas: "message message message message send" "attach search reasoning . ."; gap: 4px; } - #attachImage, #webSearch, #reasoning { width: 36px; height: 36px; min-height: 36px; border-radius: 12px; } -} #webSearch, #reasoning { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 17px; } #webSearch { grid-area: search; } #reasoning { grid-area: reasoning; } @@ -524,7 +530,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); } #msgInput { grid-area: message; min-height: 44px; max-height: 160px; padding: 11px 13px; resize: none; background: transparent; border: 0; box-shadow: none; } #send { grid-area: send; width: 44px; min-width: 44px; height: 44px; min-height: 44px; padding: 0; display: grid; place-items: center; border-radius: 17px; } #send::after { content: "\2191"; font-size: 1.1rem; font-weight: 800; } -#send.loading::after { content: "■"; font-size: .85rem; } +#stopGeneration { grid-area: stop; width: 58px; height: 44px; min-height: 44px; padding: 0 7px; font-size: .76rem; font-weight: 800; border-radius: 15px; } .generation-settings-grid { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 9px; } .generation-settings-grid .field-wide, .generation-settings-grid button { grid-column: 1 / -1; } .generation-advanced { margin-top: 6px; border-top: 1px solid var(--border); } @@ -689,6 +695,14 @@ input:focus, textarea:focus, select:focus { border-color: var(--brand); } .messages-empty-actions { display: grid; } .chat-drawer-action-list { grid-template-columns: 1fr; } .chat-drawer-action-list > * { grid-column: auto; } + .conversation-search { padding: 6px 10px; flex-wrap: wrap; gap: 5px; } + .conversation-search label { width: 100%; } + .conversation-search input { flex: 1 1 110px; } + .conversation-search span { min-width: 55px; } + .input-container { grid-template-columns: repeat(3, minmax(0, 1fr)) 44px 58px; grid-template-areas: "message message message send stop" "attach search reasoning . ."; gap: 4px; } + .input-container:not(:has(#stopGeneration:not([hidden]))) { grid-template-columns: repeat(3, minmax(0, 1fr)) 44px; grid-template-areas: "message message message send" "attach search reasoning ."; } + #attachImage, #webSearch, #reasoning { width: 100%; height: 49px; min-height: 49px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; border-radius: 12px; } + .composer-tool-label { display: inline; font-size: .63rem; line-height: 1; } } @media (hover: none) { .msg-actions { opacity: 1; } } diff --git a/public/js/composerController.js b/public/js/composerController.js index eec5825..50079bc 100644 --- a/public/js/composerController.js +++ b/public/js/composerController.js @@ -7,16 +7,17 @@ export function createComposerController({ state, elements, attachmentComposer, webSearchButton, reasoningToggle, webSearchToggle, setChatActivity, updateComposerPlaceholder, setNotice, createNewChat, getChatById, updateWorkspaceCopy, renderChatList, addMessage, updateChatActionState, scrollMessagesToBottom, - setMessageContent, setActiveChat, loadSummary, markOnboardingCompleted, maybeShowPersonaNudge, + setMessageContent, announceResponding, announceReply, announceResponseStatus, setActiveChat, loadSummary, markOnboardingCompleted, maybeShowPersonaNudge, api = defaultApi }) { - const {msgInput, sendBtn, messagesDiv, modelSelect} = elements; + const {msgInput, sendBtn, stopBtn, messagesDiv, modelSelect} = elements; + const composerHint = document.getElementById("composerHint"); let sendController = null; function updateSendState() { const hasText = Boolean(msgInput.value.trim()); - sendBtn.disabled = state.isProcessing ? false : attachmentComposer.pending || (!hasText && !attachmentComposer.attachments.length); - sendBtn.setAttribute("aria-label", state.isProcessing ? "Stop generation" : "Send message"); + sendBtn.disabled = state.isProcessing || attachmentComposer.pending || (!hasText && !attachmentComposer.attachments.length); + stopBtn.hidden = !state.isProcessing; } function resizeComposerInput() { @@ -26,21 +27,22 @@ export function createComposerController({ function setLoadingState(loading, overlayOptions = null) { state.isProcessing = loading; - msgInput.disabled = loading; webSearchButton.disabled = loading; reasoningToggle.setBusy(loading); attachmentComposer.setBusy(loading); - sendBtn.classList.toggle("loading", loading); 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..."; + msgInput.placeholder = "Draft your next message..."; + composerHint.textContent = "Draft your next message · Enter for new line"; + announceResponding(); if (overlayOptions !== false) setChatActivity(true, overlayOptions || undefined); } else { setChatActivity(false); updateComposerPlaceholder(); + composerHint.textContent = "Enter to send · Shift+Enter for new line"; } updateSendState(); } @@ -76,16 +78,16 @@ export function createComposerController({ let userCard, loadingMsg, rendering, completedReply = null; let completedThinking = "", completedSources = null; const userMessage = {role: "user", content: message, attachments}; + if (!retryMessage) { + msgInput.value = ""; + state.chatDrafts.set(chatId ?? "new", ""); + resizeComposerInput(); + } const clearSentDraft = () => { - if (state.chatDrafts.get(chatId)?.trim() === message) state.chatDrafts.delete(chatId); attachmentComposer.clearSentDraft(chatId, attachments.map(item => item.id)); }; try { setLoadingState(true, false); - if (retryMessage && !msgInput.value.trim()) { - msgInput.value = message; - resizeComposerInput(); - } if (!chatId) { const created = await createNewChat({preserveComposer: true, signal}); signal.throwIfAborted(); @@ -152,6 +154,7 @@ export function createComposerController({ state.currentMessages.push(reply); if (loadingMsg?.isConnected) loadingMsg.replaceWith(addMessage(reply, false, false, {detached: true})); else addMessage(reply); + announceReply(completedReply); } else loadingMsg?.remove(); reasoningToggle.reset(); if (visible()) { @@ -160,7 +163,6 @@ export function createComposerController({ const refreshed = await setActiveChat(chatId); if (refreshed?.error) throw new Error(refreshed.error); if (visible()) { - if (msgInput.value.trim() === message) { msgInput.value = ""; resizeComposerInput(); } if (msgInput.value) state.chatDrafts.set(chatId, msgInput.value); else state.chatDrafts.delete(chatId); const currentAttachments = attachmentComposer.attachments; @@ -178,11 +180,16 @@ export function createComposerController({ } catch (error) { loadingMsg?.remove(); const messageText = signal.aborted ? "Generation stopped." : error?.message || "Unable to send message."; + if (visible() && completedReply === null) announceResponseStatus(messageText); if (completedReply !== null) { setNotice(`Reply sent, but the conversation could not be refreshed: ${messageText}`, "error"); } else { if (visible()) { - if (!msgInput.value.trim()) { msgInput.value = message; resizeComposerInput(); } + if (!msgInput.value.trim()) { + msgInput.value = message; + state.chatDrafts.set(state.activeChatId ?? "new", message); + resizeComposerInput(); + } userMessage.deliveryStatus = "failed"; userMessage.errorMessage = messageText; if (userCard?.isConnected) userCard.replaceWith(addMessage(userMessage, true, false, {detached: true})); @@ -193,7 +200,7 @@ export function createComposerController({ rendering?.dispose(); sendController = null; state.sendingChatId = null; - setLoadingState(false); msgInput.focus(); updateChatActionState(); + setLoadingState(false); updateChatActionState(); } } diff --git a/public/js/messageActions.js b/public/js/messageActions.js index daf51f4..c69b89b 100644 --- a/public/js/messageActions.js +++ b/public/js/messageActions.js @@ -1,7 +1,7 @@ import {post, put, del} from "./api.js"; import {regenerateInline} from "./regeneration.js"; -export function createMessageActions({conversation, setNotice, promptPopup, confirmPopup, setActiveChat, openCreatedChat, setLoadingState, loadSummary, renderMessages, addMessage, setMessageContent, webSearchToggle, reasoningToggle, modelSelect, messagesDiv, msgInput}) { +export function createMessageActions({conversation, setNotice, promptPopup, confirmPopup, setActiveChat, openCreatedChat, setLoadingState, loadSummary, renderMessages, addMessage, setMessageContent, announceReply, announceResponseStatus, webSearchToggle, reasoningToggle, modelSelect, messagesDiv, msgInput}) { function getMessageIndexById(messageId) { return conversation.currentMessages.findIndex((message) => message.id === messageId); } @@ -106,8 +106,12 @@ export function createMessageActions({conversation, setNotice, promptPopup, conf webSearch: webSearchToggle.enabled, confirmed: true }, {timeoutMs: 660000}); - if (res.error) return setNotice(res.error, "error"); - if (conversation.activeChatId === sourceChatId) await openCreatedChat(res.chat); + if (res.error) { announceResponseStatus(res.error); return setNotice(res.error, "error"); } + if (conversation.activeChatId === sourceChatId) { + await openCreatedChat(res.chat); + const reply = conversation.currentMessages.at(-1); + if (reply?.role === "bot") announceReply(reply.content); + } return setNotice("Created the edited chat branch.", "success"); } finally { conversation.regenerationChatId = null; @@ -184,11 +188,13 @@ export function createMessageActions({conversation, setNotice, promptPopup, conf syncRetryStateFromMessage(conversation.currentMessages[index]); const rendered = addMessage(conversation.currentMessages[index], false, false, { messageIndex: index, detached: true }); card.replaceChildren(...rendered.childNodes); + announceReply(message.content); } }); reasoningToggle.reset(); setNotice("Reply regenerated.", "success"); } catch (error) { + announceResponseStatus(error.message || "Unable to regenerate message."); setNotice(error.message || "Unable to regenerate message.", "error"); } finally { conversation.regenerationChatId = null; diff --git a/public/js/messageRenderer.js b/public/js/messageRenderer.js index 2f6b408..9dd0633 100644 --- a/public/js/messageRenderer.js +++ b/public/js/messageRenderer.js @@ -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 = `
Assistant
-
+