1
0
mirror of https://github.com/coalaura/whiskr.git synced 2025-09-08 17:06:42 +00:00

improved auto-scroll and waiting indicator

This commit is contained in:
Laura
2025-08-31 23:10:14 +02:00
parent eeb8c22415
commit 6471aeb51b
5 changed files with 250 additions and 57 deletions

View File

@@ -1,5 +1,5 @@
# Tool use # 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?}) search_web({query, num_results?, intent?, recency?, domains?})
- Fresh info & citations. Keep query short; add month/year if freshness matters. - Fresh info & citations. Keep query short; add month/year if freshness matters.

View File

@@ -221,9 +221,23 @@ body:not(.loading) #loading {
} }
#title.refreshing #title-text { #title.refreshing #title-text {
position: relative;
filter: blur(3px); 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 { #messages {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -320,6 +334,7 @@ body:not(.loading) #loading {
margin-left: 12px; margin-left: 12px;
} }
.message:not(.has-text) .text,
.message:not(.has-tags) .tags { .message:not(.has-tags) .tags {
display: none; display: none;
} }
@@ -395,6 +410,14 @@ body:not(.loading) #loading {
margin-top: 16px; 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-reasoning:not(.has-text):not(.errored) div.text,
.message.has-tool: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, .message.has-files:not(.has-text):not(.errored) div.text,
@@ -611,9 +634,30 @@ body:not(.loading) #loading {
font-style: italic; font-style: italic;
} }
.message:empty.receiving .text::before, .message .loader {
.message.waiting .text::before { display: none;
content: ". . ."; }
.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 { .statistics {
@@ -710,6 +754,10 @@ body:not(.loading) #loading {
cursor: n-resize; cursor: n-resize;
} }
#chat:has(.has-files) #resize-bar {
top: 46px;
}
#attachments { #attachments {
position: absolute; position: absolute;
top: 2px; top: 2px;
@@ -1035,6 +1083,10 @@ label[for="reasoning-tokens"] {
background-image: url(icons/screen.svg); background-image: url(icons/screen.svg);
} }
#scrolling.not-following {
opacity: 0.5;
}
#json { #json {
background-image: url(icons/json-off.svg); background-image: url(icons/json-off.svg);
} }
@@ -1195,6 +1247,32 @@ label[for="reasoning-tokens"] {
background: #89bb77; 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 { @keyframes wiggling {
0% { 0% {
transform: translate(0px); transform: translate(0px);

View File

@@ -33,7 +33,7 @@
<div id="title-text"></div> <div id="title-text"></div>
</div> </div>
<div id="messages"></div> <div id="messages" tabindex="0"></div>
<div id="chat"> <div id="chat">
<button id="top" class="hidden" title="Scroll to top"></button> <button id="top" class="hidden" title="Scroll to top"></button>

View File

@@ -1,7 +1,6 @@
(() => { (() => {
const $version = document.getElementById("version"), const $version = document.getElementById("version"),
$total = document.getElementById("total"), $total = document.getElementById("total"),
$notifications = document.getElementById("notifications"),
$title = document.getElementById("title"), $title = document.getElementById("title"),
$titleRefresh = document.getElementById("title-refresh"), $titleRefresh = document.getElementById("title-refresh"),
$titleText = document.getElementById("title-text"), $titleText = document.getElementById("title-text"),
@@ -34,7 +33,8 @@
$password = document.getElementById("password"), $password = document.getElementById("password"),
$login = document.getElementById("login"); $login = document.getElementById("login");
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; const nearBottom = 22,
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
let platform = ""; let platform = "";
@@ -50,6 +50,8 @@
promptList = []; promptList = [];
let autoScrolling = false, let autoScrolling = false,
followTail = true,
awaitingScroll = false,
jsonMode = false, jsonMode = false,
searchTool = false, searchTool = false,
chatTitle = false; chatTitle = false;
@@ -66,34 +68,6 @@
$total.textContent = formatMoney(totalCost); $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() { function updateTitle() {
const title = chatTitle || (messages.length ? "New Chat" : ""); const title = chatTitle || (messages.length ? "New Chat" : "");
@@ -106,26 +80,44 @@
storeValue("title", chatTitle); storeValue("title", chatTitle);
} }
function distanceFromBottom() {
return $messages.scrollHeight - ($messages.scrollTop + $messages.clientHeight);
}
function updateScrollButton() { function updateScrollButton() {
const bottom = $messages.scrollHeight - ($messages.scrollTop + $messages.offsetHeight); const bottom = distanceFromBottom();
$top.classList.toggle("hidden", $messages.scrollTop < 80); $top.classList.toggle("hidden", $messages.scrollTop < 80);
$bottom.classList.toggle("hidden", bottom < 80); $bottom.classList.toggle("hidden", bottom < 80);
} }
function setFollowTail(follow) {
followTail = follow;
$scrolling.classList.toggle("not-following", !followTail);
}
function scroll(force = false, instant = false) { function scroll(force = false, instant = false) {
if (!autoScrolling && !force) { if (awaitingScroll || !(followTail || force)) {
updateScrollButton(); updateScrollButton();
return; return;
} }
setTimeout(() => { awaitingScroll = true;
requestAnimationFrame(() => {
awaitingScroll = false;
if (!followTail && !force) {
return;
}
$messages.scroll({ $messages.scroll({
top: $messages.scrollHeight, top: $messages.scrollHeight,
behavior: instant ? "instant" : "smooth", behavior: instant ? "instant" : "smooth",
}); });
}, 0); });
} }
function preloadIcons(icons) { function preloadIcons(icons) {
@@ -215,6 +207,13 @@
this.#_message.appendChild(_body); this.#_message.appendChild(_body);
// loader
const _loader = make("div", "loader");
_loader.innerHTML = "<span></span>".repeat(3);
_body.appendChild(_loader);
// message files // message files
this.#_files = make("div", "files"); this.#_files = make("div", "files");
@@ -233,12 +232,14 @@
_reasoning.appendChild(_toggle); _reasoning.appendChild(_toggle);
_toggle.addEventListener("click", () => { _toggle.addEventListener("click", () => {
_reasoning.classList.toggle("expanded"); let delta = this.#updateReasoningHeight() + 16; // margin
if (_reasoning.classList.contains("expanded")) { if (!_reasoning.classList.toggle("expanded")) {
this.#updateReasoningHeight(); delta = -delta;
} }
setFollowTail(distanceFromBottom() + delta <= nearBottom);
updateScrollButton(); updateScrollButton();
}); });
@@ -282,7 +283,13 @@
this.#_tool.appendChild(_call); this.#_tool.appendChild(_call);
_call.addEventListener("click", () => { _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(); updateScrollButton();
}); });
@@ -324,6 +331,10 @@
_optCollapse.addEventListener("click", () => { _optCollapse.addEventListener("click", () => {
this.#_message.classList.toggle("collapsed"); this.#_message.classList.toggle("collapsed");
updateScrollButton();
setFollowTail(distanceFromBottom() <= nearBottom);
this.#save(); this.#save();
}); });
@@ -432,13 +443,20 @@
} }
#updateReasoningHeight() { #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() { #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) { #morph(from, to) {
@@ -524,8 +542,6 @@
this.#_message.classList.toggle("has-tool", !!this.#tool); this.#_message.classList.toggle("has-tool", !!this.#tool);
this.#updateToolHeight();
noScroll || scroll(); noScroll || scroll();
updateScrollButton(); updateScrollButton();
@@ -565,8 +581,6 @@
if (!only || only === "reasoning") { if (!only || only === "reasoning") {
this.#patch("reasoning", this.#_reasoning, this.#reasoning, () => { this.#patch("reasoning", this.#_reasoning, this.#reasoning, () => {
this.#updateReasoningHeight();
noScroll || scroll(); noScroll || scroll();
updateScrollButton(); updateScrollButton();
@@ -829,6 +843,10 @@
this.#render(false, true); this.#render(false, true);
this.#save(); this.#save();
} }
setFollowTail(distanceFromBottom() <= nearBottom);
updateScrollButton();
} }
delete() { delete() {
@@ -951,6 +969,10 @@
} }
} }
if (autoScrolling) {
setFollowTail(true);
}
let temperature = parseFloat($temperature.value); let temperature = parseFloat($temperature.value);
if (Number.isNaN(temperature) || temperature < 0 || temperature > 2) { if (Number.isNaN(temperature) || temperature < 0 || temperature > 2) {
@@ -1308,7 +1330,9 @@
} }
if (message.tags) { if (message.tags) {
message.tags.forEach(tag => obj.addTag(tag)); message.tags.forEach(tag => {
obj.addTag(tag);
});
} }
if (message.tool) { if (message.tool) {
@@ -1324,10 +1348,9 @@
updateTitle(); updateTitle();
scroll(); requestAnimationFrame(() => {
$messages.scrollTop = $messages.scrollHeight;
// small fix, sometimes when hard reloading we don't scroll all the way });
setTimeout(scroll, 250);
} }
let attachments = []; let attachments = [];
@@ -1437,7 +1460,17 @@
updateScrollButton(); updateScrollButton();
}); });
$messages.addEventListener("wheel", event => {
if (event.deltaY < 0) {
setFollowTail(false);
} else {
setFollowTail(distanceFromBottom() - event.deltaY <= nearBottom);
}
});
$bottom.addEventListener("click", () => { $bottom.addEventListener("click", () => {
setFollowTail(true);
$messages.scroll({ $messages.scroll({
top: $messages.scrollHeight, top: $messages.scrollHeight,
behavior: "smooth", behavior: "smooth",
@@ -1445,6 +1478,8 @@
}); });
$top.addEventListener("click", () => { $top.addEventListener("click", () => {
setFollowTail($messages.scrollHeight <= $messages.clientHeight);
$messages.scroll({ $messages.scroll({
top: 0, top: 0,
behavior: "smooth", behavior: "smooth",
@@ -1452,7 +1487,7 @@
}); });
$resizeBar.addEventListener("mousedown", event => { $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) { if (event.button === 1) {
$chat.style.height = ""; $chat.style.height = "";
@@ -1671,6 +1706,8 @@
autoScrolling = !autoScrolling; autoScrolling = !autoScrolling;
if (autoScrolling) { if (autoScrolling) {
setFollowTail(true);
$scrolling.title = "Turn off auto-scrolling"; $scrolling.title = "Turn off auto-scrolling";
$scrolling.classList.add("on"); $scrolling.classList.add("on");
@@ -1729,7 +1766,7 @@
} }
const total = window.innerHeight, 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`; $chat.style.height = `${height}px`;
@@ -1744,6 +1781,52 @@
document.body.classList.remove("resizing"); 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($role);
dropdown($reasoningEffort); dropdown($reasoningEffort);

View File

@@ -329,3 +329,35 @@ async function detectPlatform() {
return `${os || "Unknown OS"}${arch ? `, ${arch}` : ""}`; 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();
};
})();