From 6471aeb51baea2fe47bf0a383f23504e11a90bf6 Mon Sep 17 00:00:00 2001 From: Laura Date: Sun, 31 Aug 2025 23:10:14 +0200 Subject: [PATCH] improved auto-scroll and waiting indicator --- internal/tools.txt | 2 +- static/css/chat.css | 84 +++++++++++++++++++- static/index.html | 2 +- static/js/chat.js | 187 ++++++++++++++++++++++++++++++++------------ static/js/lib.js | 32 ++++++++ 5 files changed, 250 insertions(+), 57 deletions(-) diff --git a/internal/tools.txt b/internal/tools.txt index ed19d90..fa98c14 100644 --- a/internal/tools.txt +++ b/internal/tools.txt @@ -1,5 +1,5 @@ # Tool use -Use at most 1 tool call per turn. You have %d turns with tool calls total. +Use at most 1 tool call per turn. You have %d turns with tool calls total. You cannot call multiple tools at once. If you have multiple turns you can call more tools in later turns. Match the tool arguments exactly. search_web({query, num_results?, intent?, recency?, domains?}) - Fresh info & citations. Keep query short; add month/year if freshness matters. diff --git a/static/css/chat.css b/static/css/chat.css index 645707c..08ca7a5 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -221,9 +221,23 @@ body:not(.loading) #loading { } #title.refreshing #title-text { + position: relative; filter: blur(3px); } +#title.refreshing #title-text::before { + content: ""; + position: absolute; + width: 38px; + height: 100%; + top: 0; + left: 0; + animation: swivel 1.2s ease-in-out infinite; + background: #6e738d; + opacity: 0.5; + border-radius: 6px; +} + #messages { display: flex; flex-direction: column; @@ -320,6 +334,7 @@ body:not(.loading) #loading { margin-left: 12px; } +.message:not(.has-text) .text, .message:not(.has-tags) .tags { display: none; } @@ -395,6 +410,14 @@ body:not(.loading) #loading { margin-top: 16px; } +.reasoning-text strong { + display: block; +} + +.reasoning-text strong:not(:first-child) { + margin-top: 14px; +} + .message.has-reasoning:not(.has-text):not(.errored) div.text, .message.has-tool:not(.has-text):not(.errored) div.text, .message.has-files:not(.has-text):not(.errored) div.text, @@ -611,9 +634,30 @@ body:not(.loading) #loading { font-style: italic; } -.message:empty.receiving .text::before, -.message.waiting .text::before { - content: ". . ."; +.message .loader { + display: none; +} + +.message.receiving:not(.has-text) .loader, +.message.waiting .loader { + display: flex; + gap: 8px; +} + +.message .loader span { + width: 8px; + height: 8px; + border-radius: 50%; + background: #939ab7; + animation: bounce 1.4s infinite ease-in-out; +} + +.message .loader span:nth-child(2) { + animation-delay: 0.2s; +} + +.message .loader span:nth-child(3) { + animation-delay: 0.4s; } .statistics { @@ -710,6 +754,10 @@ body:not(.loading) #loading { cursor: n-resize; } +#chat:has(.has-files) #resize-bar { + top: 46px; +} + #attachments { position: absolute; top: 2px; @@ -1035,6 +1083,10 @@ label[for="reasoning-tokens"] { background-image: url(icons/screen.svg); } +#scrolling.not-following { + opacity: 0.5; +} + #json { background-image: url(icons/json-off.svg); } @@ -1195,6 +1247,32 @@ label[for="reasoning-tokens"] { background: #89bb77; } +@keyframes swivel { + + 0%, + 100% { + left: 0px; + } + + 50% { + left: calc(100%); + transform: translateX(-100%); + } +} + +@keyframes bounce { + + 0%, + 75%, + 100% { + transform: translateY(0); + } + + 25% { + transform: translateY(-4px); + } +} + @keyframes wiggling { 0% { transform: translate(0px); diff --git a/static/index.html b/static/index.html index 6cea0c1..672a729 100644 --- a/static/index.html +++ b/static/index.html @@ -33,7 +33,7 @@
-
+
diff --git a/static/js/chat.js b/static/js/chat.js index 528a280..da89482 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -1,7 +1,6 @@ (() => { const $version = document.getElementById("version"), $total = document.getElementById("total"), - $notifications = document.getElementById("notifications"), $title = document.getElementById("title"), $titleRefresh = document.getElementById("title-refresh"), $titleText = document.getElementById("title-text"), @@ -34,7 +33,8 @@ $password = document.getElementById("password"), $login = document.getElementById("login"); - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const nearBottom = 22, + timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; let platform = ""; @@ -50,6 +50,8 @@ promptList = []; let autoScrolling = false, + followTail = true, + awaitingScroll = false, jsonMode = false, searchTool = false, chatTitle = false; @@ -66,34 +68,6 @@ $total.textContent = formatMoney(totalCost); } - async function notify(msg, persistent = false) { - console.warn(msg); - - const notification = make("div", "notification", "off-screen"); - - notification.textContent = msg instanceof Error ? msg.message : msg; - - $notifications.appendChild(notification); - - await wait(250); - - notification.classList.remove("off-screen"); - - if (persistent) { - return; - } - - await wait(5000); - - notification.style.height = `${notification.getBoundingClientRect().height}px`; - - notification.classList.add("off-screen"); - - await wait(250); - - notification.remove(); - } - function updateTitle() { const title = chatTitle || (messages.length ? "New Chat" : ""); @@ -106,26 +80,44 @@ storeValue("title", chatTitle); } + function distanceFromBottom() { + return $messages.scrollHeight - ($messages.scrollTop + $messages.clientHeight); + } + function updateScrollButton() { - const bottom = $messages.scrollHeight - ($messages.scrollTop + $messages.offsetHeight); + const bottom = distanceFromBottom(); $top.classList.toggle("hidden", $messages.scrollTop < 80); $bottom.classList.toggle("hidden", bottom < 80); } + function setFollowTail(follow) { + followTail = follow; + + $scrolling.classList.toggle("not-following", !followTail); + } + function scroll(force = false, instant = false) { - if (!autoScrolling && !force) { + if (awaitingScroll || !(followTail || force)) { updateScrollButton(); return; } - setTimeout(() => { + awaitingScroll = true; + + requestAnimationFrame(() => { + awaitingScroll = false; + + if (!followTail && !force) { + return; + } + $messages.scroll({ top: $messages.scrollHeight, behavior: instant ? "instant" : "smooth", }); - }, 0); + }); } function preloadIcons(icons) { @@ -215,6 +207,13 @@ this.#_message.appendChild(_body); + // loader + const _loader = make("div", "loader"); + + _loader.innerHTML = "".repeat(3); + + _body.appendChild(_loader); + // message files this.#_files = make("div", "files"); @@ -233,12 +232,14 @@ _reasoning.appendChild(_toggle); _toggle.addEventListener("click", () => { - _reasoning.classList.toggle("expanded"); + let delta = this.#updateReasoningHeight() + 16; // margin - if (_reasoning.classList.contains("expanded")) { - this.#updateReasoningHeight(); + if (!_reasoning.classList.toggle("expanded")) { + delta = -delta; } + setFollowTail(distanceFromBottom() + delta <= nearBottom); + updateScrollButton(); }); @@ -282,7 +283,13 @@ this.#_tool.appendChild(_call); _call.addEventListener("click", () => { - this.#_tool.classList.toggle("expanded"); + let delta = this.#updateToolHeight() + 16; // margin + + if (!this.#_tool.classList.toggle("expanded")) { + delta = -delta; + } + + setFollowTail(distanceFromBottom() + delta <= nearBottom); updateScrollButton(); }); @@ -324,6 +331,10 @@ _optCollapse.addEventListener("click", () => { this.#_message.classList.toggle("collapsed"); + updateScrollButton(); + + setFollowTail(distanceFromBottom() <= nearBottom); + this.#save(); }); @@ -432,13 +443,20 @@ } #updateReasoningHeight() { - this.#_reasoning.parentNode.style.setProperty("--height", `${this.#_reasoning.scrollHeight}px`); + const height = this.#_reasoning.scrollHeight; + + this.#_reasoning.parentNode.style.setProperty("--height", `${height}px`); + + return height; } #updateToolHeight() { - const result = this.#_tool.querySelector(".result"); + const result = this.#_tool.querySelector(".result"), + height = result.scrollHeight; - this.#_tool.style.setProperty("--height", `${result.scrollHeight}px`); + this.#_tool.style.setProperty("--height", `${height}px`); + + return height; } #morph(from, to) { @@ -524,8 +542,6 @@ this.#_message.classList.toggle("has-tool", !!this.#tool); - this.#updateToolHeight(); - noScroll || scroll(); updateScrollButton(); @@ -565,8 +581,6 @@ if (!only || only === "reasoning") { this.#patch("reasoning", this.#_reasoning, this.#reasoning, () => { - this.#updateReasoningHeight(); - noScroll || scroll(); updateScrollButton(); @@ -829,6 +843,10 @@ this.#render(false, true); this.#save(); } + + setFollowTail(distanceFromBottom() <= nearBottom); + + updateScrollButton(); } delete() { @@ -951,6 +969,10 @@ } } + if (autoScrolling) { + setFollowTail(true); + } + let temperature = parseFloat($temperature.value); if (Number.isNaN(temperature) || temperature < 0 || temperature > 2) { @@ -1308,7 +1330,9 @@ } if (message.tags) { - message.tags.forEach(tag => obj.addTag(tag)); + message.tags.forEach(tag => { + obj.addTag(tag); + }); } if (message.tool) { @@ -1324,10 +1348,9 @@ updateTitle(); - scroll(); - - // small fix, sometimes when hard reloading we don't scroll all the way - setTimeout(scroll, 250); + requestAnimationFrame(() => { + $messages.scrollTop = $messages.scrollHeight; + }); } let attachments = []; @@ -1437,7 +1460,17 @@ updateScrollButton(); }); + $messages.addEventListener("wheel", event => { + if (event.deltaY < 0) { + setFollowTail(false); + } else { + setFollowTail(distanceFromBottom() - event.deltaY <= nearBottom); + } + }); + $bottom.addEventListener("click", () => { + setFollowTail(true); + $messages.scroll({ top: $messages.scrollHeight, behavior: "smooth", @@ -1445,6 +1478,8 @@ }); $top.addEventListener("click", () => { + setFollowTail($messages.scrollHeight <= $messages.clientHeight); + $messages.scroll({ top: 0, behavior: "smooth", @@ -1452,7 +1487,7 @@ }); $resizeBar.addEventListener("mousedown", event => { - const isAtBottom = $messages.scrollHeight - ($messages.scrollTop + $messages.offsetHeight) <= 10; + const isAtBottom = $messages.scrollHeight - ($messages.scrollTop + $messages.clientHeight) <= 10; if (event.button === 1) { $chat.style.height = ""; @@ -1671,6 +1706,8 @@ autoScrolling = !autoScrolling; if (autoScrolling) { + setFollowTail(true); + $scrolling.title = "Turn off auto-scrolling"; $scrolling.classList.add("on"); @@ -1729,7 +1766,7 @@ } const total = window.innerHeight, - height = clamp(window.innerHeight - event.clientY, 100, total - 240); + height = clamp(window.innerHeight - event.clientY + (attachments.length ? 50 : 0), 100, total - 240); $chat.style.height = `${height}px`; @@ -1744,6 +1781,52 @@ document.body.classList.remove("resizing"); }); + addEventListener("keydown", event => { + if (["TEXTAREA", "INPUT", "SELECT"].includes(document.activeElement?.tagName)) { + return; + } + + let delta; + + switch (event.key) { + case "PageUp": + case "ArrowUp": + delta = event.key === "PageUp" ? -$messages.clientHeight : -120; + + setFollowTail(false); + + break; + case "PageDown": + case "ArrowDown": + delta = event.key === "PageDown" ? $messages.clientHeight : 120; + + setFollowTail(distanceFromBottom() - delta <= nearBottom); + + break; + case "Home": + delta = -$messages.scrollTop; + + setFollowTail(false); + + break; + case "End": + delta = $messages.scrollHeight - $messages.clientHeight - $messages.scrollTop; + + setFollowTail(true); + + break; + } + + if (delta) { + event.preventDefault(); + + $messages.scrollBy({ + top: delta, + behavior: "smooth", + }); + } + }); + dropdown($role); dropdown($reasoningEffort); diff --git a/static/js/lib.js b/static/js/lib.js index 4eddc21..983b730 100644 --- a/static/js/lib.js +++ b/static/js/lib.js @@ -329,3 +329,35 @@ async function detectPlatform() { return `${os || "Unknown OS"}${arch ? `, ${arch}` : ""}`; } + +(() => { + const $notifications = document.getElementById("notifications"); + + window.notify = async (msg, persistent = false) => { + console.warn(msg); + + const notification = make("div", "notification", "off-screen"); + + notification.textContent = msg instanceof Error ? msg.message : msg; + + $notifications.appendChild(notification); + + await wait(250); + + notification.classList.remove("off-screen"); + + if (persistent) { + return; + } + + await wait(5000); + + notification.style.height = `${notification.getBoundingClientRect().height}px`; + + notification.classList.add("off-screen"); + + await wait(250); + + notification.remove(); + }; +})();