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();
+ };
+})();