Files
LLeMbas/src/lembas/web/static/js/ui.js
T
HomerandClaude Opus 5 28390095a9 A phone, and how much of this could not be used on one
The sidebar was a 280px panel laid over the page below the phone breakpoint,
opened from first paint, with the only control that closed it underneath it --
and that control existed on /chat and on none of the seven other pages carrying
a sidebar, Settings included. It starts closed at that width now, slides, dims
the page behind it, and closes by tapping beside it, by Escape, or by its own
button, which is inside the drawer where it can be reached.

Everything a finger has to hit was 36px, or 28 for renaming a chat, every action
on a message and every panel's close button. Raising --control-h under a coarse
pointer is the only fix that reaches all forty of them, which is what that token
is for. The row and message actions were also hover-only, so on a phone they did
not exist at all.

Installing: the splash and the browser chrome follow the instance's theme rather
than always being Moria's near-black; there are screenshots, so the install
offer is a dialog rather than a one-line bar; a new release no longer takes over
a page somebody is reading; the notification badge is a silhouette rather than a
grey square; and a browser rotating its own subscription no longer ends
notifications for good.

Every request now says it is happening -- nothing did before, so anything slower
than a few milliseconds looked like a click that had not registered.

A chat can be archived. The column has been filtered on in four places since
folders arrived and written by nothing, which is what made it look built.

chat.css may contain media queries. The ban protected the composer toolbar from
being "fixed" with a breakpoint; that guarantee is asserted directly now, and
the old test would have passed a version of the file that wrapped the toolbar
without one.

scripts/shoot.py is the instrument all of this was found with: it renders a page
through TestClient into a real headless browser at a real size and refuses to
run if an asset URL was left pointing at testserver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 13:39:35 +00:00

1172 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
Toasts and dialogs.
Replaces window.confirm/prompt, which cannot be styled, ignore the theme, and
block the whole tab. Dialogs are built on <dialog>, so focus trapping, Escape
and inertness of the page behind come from the browser rather than from
hand-written key handling.
Everything returns a Promise, so callers read as if they were still using the
built-ins:
if (await lembas.confirm({ message: "Delete this?" })) { ... }
const name = await lembas.prompt({ message: "New name", value: old });
lembas.notify("Saved.", { kind: "success" });
*/
(function () {
"use strict";
var TOAST_MS = 4000;
function el(tag, className, text) {
var node = document.createElement(tag);
if (className) node.className = className;
// textContent, never innerHTML: these messages carry filenames, chat
// titles and upstream error text, none of which is ours to trust.
if (text != null) node.textContent = text;
return node;
}
/* --- Toasts ------------------------------------------------------------ */
function toastHost() {
var host = document.getElementById("toasts");
if (!host) {
host = el("div", "toasts");
host.id = "toasts";
// Announced politely so a screen reader hears it without being yanked
// away from whatever it was reading.
host.setAttribute("role", "status");
host.setAttribute("aria-live", "polite");
document.body.appendChild(host);
}
return host;
}
function notify(message, options) {
options = options || {};
var toast = el("div", "toast toast--" + (options.kind || "info"));
toast.appendChild(el("span", "toast__text", message));
/* Some news is worth acting on where it is read: "a new version is ready"
with no way to take it is a sentence that sends somebody looking for a
menu. One action, never two -- a toast is not a dialog, and anything
needing a choice should be one. */
if (options.action && options.action.label) {
var act = el("button", "btn btn--sm toast__action", options.action.label);
act.type = "button";
act.addEventListener("click", function () {
dismiss(toast);
if (options.action.run) options.action.run();
});
toast.appendChild(act);
}
var close = el("button", "toast__close");
close.type = "button";
close.setAttribute("aria-label", "Dismiss");
close.textContent = "×";
close.addEventListener("click", function () { dismiss(toast); });
toast.appendChild(close);
toastHost().appendChild(toast);
// Next frame, so the entry transition has a state to move from.
requestAnimationFrame(function () { toast.classList.add("is-in"); });
/* A toast offering an action must not take it away while it is being read.
Anything with a button stays until it is answered or dismissed. */
var timeout = options.timeout == null
? (options.action ? 0 : TOAST_MS)
: options.timeout;
if (timeout > 0) setTimeout(function () { dismiss(toast); }, timeout);
return toast;
}
function dismiss(toast) {
if (!toast || toast.dataset.going) return;
toast.dataset.going = "1";
toast.classList.remove("is-in");
setTimeout(function () { toast.remove(); }, 180);
}
/* --- Dialogs ----------------------------------------------------------- */
function buildDialog(options) {
var dialog = el("dialog", "dialog");
var form = el("form", "dialog__form");
form.method = "dialog";
if (options.title) form.appendChild(el("h2", "dialog__title", options.title));
if (options.message) form.appendChild(el("p", "dialog__message", options.message));
var input = null;
if (options.kind === "prompt") {
input = el("input", "input");
input.type = "text";
input.value = options.value || "";
if (options.placeholder) input.placeholder = options.placeholder;
input.setAttribute("aria-label", options.title || options.message || "Value");
form.appendChild(input);
}
var actions = el("div", "dialog__actions");
var cancel = el("button", "btn", options.cancelLabel || "Cancel");
cancel.type = "button";
cancel.value = "cancel";
actions.appendChild(cancel);
var accept = el(
"button",
"btn " + (options.danger ? "btn--danger-solid" : "btn--primary"),
options.confirmLabel || "OK"
);
accept.type = "submit";
accept.value = "accept";
actions.appendChild(accept);
form.appendChild(actions);
dialog.appendChild(form);
document.body.appendChild(dialog);
return { dialog: dialog, form: form, input: input, cancel: cancel, accept: accept };
}
function open(options) {
return new Promise(function (resolve) {
var parts = buildDialog(options);
var settled = false;
function finish(value) {
if (settled) return;
settled = true;
resolve(value);
parts.dialog.close();
// Let the closing transition finish before the node disappears.
setTimeout(function () { parts.dialog.remove(); }, 200);
}
parts.cancel.addEventListener("click", function () { finish(options.kind === "prompt" ? null : false); });
parts.form.addEventListener("submit", function (event) {
event.preventDefault();
finish(options.kind === "prompt" ? (parts.input.value || "") : true);
});
// Escape and the backdrop both mean "no".
parts.dialog.addEventListener("cancel", function (event) {
event.preventDefault();
finish(options.kind === "prompt" ? null : false);
});
parts.dialog.addEventListener("click", function (event) {
if (event.target === parts.dialog) finish(options.kind === "prompt" ? null : false);
});
parts.dialog.showModal();
if (parts.input) {
parts.input.focus();
parts.input.select();
} else {
(options.danger ? parts.cancel : parts.accept).focus();
}
});
}
function confirm(options) {
if (typeof options === "string") options = { message: options };
return open(Object.assign({ kind: "confirm", confirmLabel: "OK" }, options));
}
function prompt(options) {
if (typeof options === "string") options = { message: options };
return open(Object.assign({ kind: "prompt", confirmLabel: "Save" }, options));
}
window.lembas = window.lembas || {};
window.lembas.notify = notify;
window.lembas.confirm = confirm;
window.lembas.prompt = prompt;
/* --- htmx integration --------------------------------------------------
hx-confirm normally calls window.confirm. Intercepting the event lets every
existing hx-confirm attribute keep working while getting the themed dialog,
with no change at the call sites. */
document.addEventListener("htmx:confirm", function (event) {
if (!event.detail.question) return; // no confirmation asked for
event.preventDefault();
var trigger = event.detail.elt;
confirm({
title: trigger && trigger.dataset.confirmTitle,
message: event.detail.question,
confirmLabel: (trigger && trigger.dataset.confirmLabel) || "Delete",
danger: !trigger || trigger.dataset.confirmDanger !== "false",
}).then(function (ok) {
if (ok) event.detail.issueRequest(true);
});
});
/* A submit button that acts on its own (formaction) rather than the form it
sits in. Confirming the whole form would be wrong: the same form also has
a plain Save. */
document.addEventListener("click", function (event) {
var button = event.target.closest("[data-confirm-button]");
if (!button || button.dataset.confirmed) return;
event.preventDefault();
event.stopPropagation();
confirm({
title: button.dataset.confirmTitle,
message: button.dataset.confirmButton,
confirmLabel: button.dataset.confirmLabel || "Delete",
danger: button.dataset.confirmDanger !== "false",
}).then(function (ok) {
if (!ok) return;
button.dataset.confirmed = "1";
button.click();
delete button.dataset.confirmed;
});
}, true);
/* Asking for one line of text before a request goes out: renaming a folder,
renaming a chat.
Deliberately NOT htmx's own hx-prompt. htmx calls the browser's prompt()
synchronously and only then fires htmx:prompt with the answer already in
hand -- so intercepting the event cannot supply a different one, and the
native box appears regardless. Cancelling the event only aborts the
request. This is the data-confirm-button shape instead: swallow the click,
ask in our own dialog, write the answer where htmx will collect it, and
click again behind a guard flag.
The answer goes into hx-vals as a normal field rather than into a header,
because every route that wants it already reads a form. htmx reads
attributes when the request is built, so setting it just before the second
click is enough. It is JSON.stringify'd, never concatenated: a folder
called `"` would otherwise produce hx-vals that does not parse, and the
request would go out with the field missing rather than with the name. */
document.addEventListener("click", function (event) {
var el = event.target.closest("[data-prompt]");
if (!el || el.dataset.prompted) return;
event.preventDefault();
event.stopPropagation();
var field = el.dataset.promptField || "name";
prompt({
title: el.dataset.promptTitle,
message: el.dataset.prompt,
value: el.dataset.promptValue || "",
confirmLabel: el.dataset.promptLabel || "Save",
}).then(function (value) {
/* null is Cancel. An empty string is somebody clearing the box and
pressing Save, which is not a rename either -- the routes ignore a
blank name, so sending it would be a request that does nothing. */
if (value === null || !String(value).trim()) return;
var values = {};
values[field] = String(value).trim();
el.setAttribute("hx-vals", JSON.stringify(values));
el.dataset.prompted = "1";
el.click();
delete el.dataset.prompted;
});
}, true);
/* Plain forms opt in with data-confirm, so they need no inline onsubmit. */
document.addEventListener("submit", function (event) {
var form = event.target;
if (!form.dataset || !form.dataset.confirm || form.dataset.confirmed) return;
event.preventDefault();
confirm({
title: form.dataset.confirmTitle,
message: form.dataset.confirm,
confirmLabel: form.dataset.confirmLabel || "Delete",
danger: form.dataset.confirmDanger !== "false",
}).then(function (ok) {
if (!ok) return;
form.dataset.confirmed = "1";
form.submit();
});
}, true);
})();
/*
The model picker.
A <select> cannot render an avatar, a description or capability badges, so
the control is built out of buttons and a hidden input. Keyboard behaviour is
written out by hand for the same reason -- there is no native widget doing it
for us.
*/
(function () {
"use strict";
function close(picker) {
var menu = picker.querySelector("[data-picker-menu]");
var toggle = picker.querySelector("[data-picker-toggle]");
if (!menu || menu.hidden) return;
menu.hidden = true;
toggle.setAttribute("aria-expanded", "false");
}
function closeAll(except) {
document.querySelectorAll("[data-picker]").forEach(function (picker) {
if (picker !== except) close(picker);
});
}
function open(picker) {
var menu = picker.querySelector("[data-picker-menu]");
var toggle = picker.querySelector("[data-picker-toggle]");
closeAll(picker);
menu.hidden = false;
toggle.setAttribute("aria-expanded", "true");
var filter = menu.querySelector("[data-picker-filter]");
if (filter) {
filter.value = "";
applyFilter(menu, "");
filter.focus();
} else {
var selected = menu.querySelector(".picker__option.is-selected") ||
menu.querySelector(".picker__option");
if (selected) selected.focus();
}
// Keep the chosen model in view when the list is long.
var current = menu.querySelector(".picker__option.is-selected");
if (current) current.scrollIntoView({ block: "nearest" });
}
function applyFilter(menu, needle) {
var shown = 0;
menu.querySelectorAll(".picker__option").forEach(function (option) {
var match = !needle || option.dataset.pickerSearch.indexOf(needle) !== -1;
option.hidden = !match;
if (match) shown += 1;
});
var empty = menu.querySelector("[data-picker-empty]");
if (empty) empty.hidden = shown > 0;
}
function choose(picker, value) {
var navigate = picker.querySelector("[data-picker-navigate]");
if (navigate) {
window.location = navigate.dataset.pickerNavigate + encodeURIComponent(value);
return;
}
var input = picker.querySelector("[data-picker-input]");
if (input) {
input.value = value;
// htmx listens for change on the input; assigning .value does not fire it.
input.dispatchEvent(new Event("change", { bubbles: true }));
}
// Reflect the choice immediately rather than waiting for a reload.
picker.querySelectorAll(".picker__option").forEach(function (option) {
var selected = option.dataset.pickerValue === value;
option.classList.toggle("is-selected", selected);
option.setAttribute("aria-selected", selected ? "true" : "false");
});
var chosen = picker.querySelector('[data-picker-value="' + CSS.escape(value) + '"]');
var label = picker.querySelector(".picker__label");
var avatar = picker.querySelector(".picker__button .picker__avatar");
if (chosen && label) {
label.textContent = chosen.querySelector(".picker__option-name").textContent.trim();
}
if (chosen && avatar) {
var source = chosen.querySelector(".picker__avatar");
if (source) avatar.replaceWith(source.cloneNode(true));
}
close(picker);
if (window.lembas && window.lembas.notify) {
window.lembas.notify("Model switched to " + (label ? label.textContent : value), {
kind: "info", timeout: 2000,
});
}
}
document.addEventListener("click", function (event) {
var toggle = event.target.closest("[data-picker-toggle]");
if (toggle) {
var picker = toggle.closest("[data-picker]");
var menu = picker.querySelector("[data-picker-menu]");
if (menu.hidden) open(picker); else close(picker);
return;
}
var option = event.target.closest("[data-picker-value]");
if (option) {
choose(option.closest("[data-picker]"), option.dataset.pickerValue);
return;
}
/* A menu item is an action, so the menu has served its purpose the moment
one is pressed. Only `choose` used to close anything, which left the
attach menu standing open over the composer after picking from it. The
item's own handler -- htmx, or the [data-attach] and [data-toggle]
delegates in app.js -- still runs; this only puts the menu away. */
var item = event.target.closest('[data-picker-menu] [role="menuitem"]');
if (item) {
close(item.closest("[data-picker]"));
return;
}
if (!event.target.closest("[data-picker-menu]")) closeAll(null);
});
document.addEventListener("input", function (event) {
if (!event.target.matches("[data-picker-filter]")) return;
applyFilter(
event.target.closest("[data-picker-menu]"),
event.target.value.trim().toLowerCase()
);
});
document.addEventListener("keydown", function (event) {
var picker = event.target.closest("[data-picker]");
if (!picker) return;
var menu = picker.querySelector("[data-picker-menu]");
if (event.key === "Escape" && !menu.hidden) {
event.preventDefault();
close(picker);
picker.querySelector("[data-picker-toggle]").focus();
return;
}
if (menu.hidden) {
if (event.key === "ArrowDown" || event.key === "Enter") {
if (event.target.matches("[data-picker-toggle]")) {
event.preventDefault();
open(picker);
}
}
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
var options = Array.prototype.filter.call(
menu.querySelectorAll(".picker__option"), function (o) { return !o.hidden; }
);
if (!options.length) return;
var at = options.indexOf(document.activeElement);
var step = event.key === "ArrowDown" ? 1 : -1;
var next = at === -1 ? 0 : (at + step + options.length) % options.length;
options[next].focus();
});
})();
/*
Something arrived.
The sidebar polls /api/chats/unread; the response carries out-of-band spans
for the dots and, when something has just landed, an HX-Trigger listing it.
Announcing it here rather than server-side keeps the wording and the timing in
one place.
Three things happen, and they are deliberately not the same thing three times:
- A **toast**, always. It is the answer for somebody who is looking at the
page, and it is the only one of the three that needs no permission and
cannot be switched off by an operating system.
- A **count in the tab title**, while the tab is not the one being looked at.
This is the part that was missing and that nothing else replaces: a schedule
that fires while you are in another tab lit a green dot in a corner you
could not see. Cleared the moment the page is looked at again, because a
badge you have to dismiss is worse than none.
- A **browser notification**, if the reader has asked for one. Only while the
page is hidden -- notifying somebody about something they are watching
happen is the behaviour that gets notifications turned off for good.
*/
(function () {
var NOTIFY_KEY = "lembas-desktop-notifications";
/* Kept per browser rather than on the account, because the *permission* is
per browser and per origin. A preference that followed somebody to a
machine where they had never granted it would be a switch that reads "on"
and does nothing. */
var baseTitle = document.title;
var pending = 0;
function wanted() {
try {
return window.localStorage.getItem(NOTIFY_KEY) === "1";
} catch (error) {
return false;
}
}
function setWanted(on) {
try {
window.localStorage.setItem(NOTIFY_KEY, on ? "1" : "0");
} catch (error) { /* private mode; the toast still works */ }
}
function retitle() {
document.title = pending > 0 ? "(" + pending + ") " + baseTitle : baseTitle;
}
/* The title is rewritten by navigation and by a rename arriving out of band,
so the base is re-read rather than captured once. Without this, renaming a
chat while something is unread would pin the old name until a reload. */
function rebase() {
var shown = document.title;
var stripped = shown.replace(/^\(\d+\)\s*/, "");
if (stripped !== baseTitle) { baseTitle = stripped; retitle(); }
}
function clear() {
if (!pending) return;
pending = 0;
retitle();
}
document.addEventListener("visibilitychange", function () {
if (!document.hidden) clear();
});
window.addEventListener("focus", clear);
function describe(items) {
if (items.length > 1) return items.length + " new arrivals";
var item = items[0];
if (item.kind === "report") return "Report filed: “" + item.title + "”";
if (item.kind === "message") return "New message";
return "Reply ready in “" + item.title + "”";
}
/* `registration.showNotification` where there is a service worker, because
`new Notification()` throws outright on Android Chrome -- so the plain
constructor alone would work on every desktop it was tested on and on no
phone at all. */
function show(message, url) {
if (!wanted() || !("Notification" in window)) return;
if (Notification.permission !== "granted") return;
/* `tag` collapses several into one: a browser left in the background for an
hour must not come back to a stack of them. */
var options = {
body: message,
tag: "lembas-unread",
icon: "/static/img/icon-192.png",
data: { url: url },
};
if (navigator.serviceWorker && navigator.serviceWorker.ready) {
navigator.serviceWorker.ready
.then(function (registration) { registration.showNotification("LLeMbas", options); })
.catch(function () { plain(options, url); });
return;
}
plain(options, url);
}
function plain(options, url) {
try {
var notification = new Notification("LLeMbas", options);
notification.onclick = function () { window.focus(); if (url) location.href = url; };
} catch (error) { /* unsupported; the toast and the title still carry it */ }
}
document.addEventListener("lembas:unread", function (event) {
var items = (event.detail && event.detail.items) || [];
if (!items.length) return;
var message = describe(items);
if (window.lembas && window.lembas.notify) {
window.lembas.notify(message, { kind: "success", timeout: 6000 });
}
if (document.hidden) {
rebase();
pending += items.length;
retitle();
show(message, items.length === 1 ? items[0].url : "");
}
});
/* Asking. `requestPermission` must be called from a gesture, so every path to
it is a button somebody pressed -- a preference restored on load and acted
on is refused by the browser with nothing said anywhere.
Shared by the Settings toggle and by the one-time offer below, because two
copies of "ask, then interpret the three answers" is two places for the
denied case to be got wrong. */
function ask(onSettled) {
if (!("Notification" in window)) {
window.lembas.notify("This browser has no notifications to offer.", { kind: "error" });
return;
}
if (Notification.permission === "denied") {
window.lembas.notify(
"Notifications are blocked for this site in your browser's own settings, " +
"which is the only place that can be undone.",
{ kind: "error", timeout: 8000 }
);
return;
}
Notification.requestPermission().then(function (result) {
if (result !== "granted") {
window.lembas.notify("Left off — nothing was changed.");
if (onSettled) onSettled();
return;
}
setWanted(true);
if (onSettled) onSettled();
subscribe().then(function (pushed) {
window.lembas.notify(
pushed
? "Notifications on, including while LLeMbas is closed."
: "Notifications on while LLeMbas is open.",
{ kind: "success", timeout: 6000 }
);
});
});
}
/*
Registering with the browser's push service.
This is what makes a notification arrive with nothing of ours running --
the poll above needs an open page, and the case worth notifying about is a
schedule firing at seven in the morning.
Best effort, always. It needs a service worker (so HTTPS or localhost), a
push service the browser can reach, and a `PushManager` that some browsers
do not have; every one of those fails to "notifications while LLeMbas is
open", which still works. A permission granted and a subscription refused
must not read as a failure, because most of the feature is still there.
*/
function subscribe() {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
return Promise.resolve(false);
}
return fetch("/api/push/key")
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (data) {
if (!data || !data.key) return false;
return navigator.serviceWorker.ready.then(function (registration) {
return registration.pushManager.subscribe({
/* Required, and not merely conventional: a browser refuses a
subscription that does not promise every push will be shown to
somebody. It is also why the worker's `push` handler always ends
in a notification unless a window is focused. */
userVisibleOnly: true,
applicationServerKey: bytes(data.key),
});
}).then(function (subscription) {
return fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription.toJSON()),
}).then(function (response) { return response.ok; });
});
})
.catch(function () { return false; });
}
function unsubscribe() {
if (!("serviceWorker" in navigator)) return Promise.resolve();
return navigator.serviceWorker.ready
.then(function (registration) { return registration.pushManager.getSubscription(); })
.then(function (subscription) {
if (!subscription) return null;
var endpoint = subscription.endpoint;
return subscription.unsubscribe().then(function () {
return fetch("/api/push/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: endpoint }),
});
});
})
.catch(function () { return null; });
}
/* base64url to bytes. `applicationServerKey` wants the raw 65-byte point and
will not take the string, and `atob` will not take base64url -- the two
substitutions and the padding are the whole of this. */
function bytes(text) {
var padded = (text + "===".slice((text.length + 3) % 4))
.replace(/-/g, "+")
.replace(/_/g, "/");
var raw = window.atob(padded);
var out = new Uint8Array(raw.length);
for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
document.addEventListener("click", function (event) {
var button = event.target.closest("[data-notify-toggle]");
if (!button) return;
event.preventDefault();
if (wanted()) {
setWanted(false);
// The registration goes too. Leaving it would mean this server kept
// sending to a browser that has been told to stop showing them, which is
// traffic to a third party for something switched off.
unsubscribe();
paint(button);
return;
}
ask(function () { paint(button); });
});
/*
Offering, once.
The browser's own permission box cannot be called on page load and should
not be: it appears with no context, and a box somebody dismisses without
reading is a permission that can only be undone in browser settings they
will never find. So the offer is ours first -- a themed dialog that says
what the notifications are for -- and pressing its button is the gesture the
browser needs.
Once, ever, per browser. "Not now" is recorded exactly as firmly as "yes":
an offer that comes back is the thing that makes people block a site to
silence it, and Settings has the switch for anybody who changes their mind.
*/
var ASKED_KEY = "lembas-notifications-asked";
function offer() {
if (!document.body || !document.body.dataset.authenticated) return;
if (!("Notification" in window) || Notification.permission !== "default") return;
try {
if (window.localStorage.getItem(ASKED_KEY)) return;
window.localStorage.setItem(ASKED_KEY, "1");
} catch (error) {
return; // no way to remember having asked, so do not ask
}
if (!window.lembas || !window.lembas.confirm) return;
window.lembas.confirm({
title: "Notifications",
message:
"Let LLeMbas tell you when a reply, a report or a scheduled run arrives " +
"while you are looking at something else?",
confirmLabel: "Turn on",
cancelLabel: "Not now",
}).then(function (yes) { if (yes) ask(scan); });
}
function paint(button) {
var on = wanted() && "Notification" in window && Notification.permission === "granted";
button.textContent = on ? "Turn off notifications" : "Turn on notifications";
button.setAttribute("aria-pressed", on ? "true" : "false");
var hint = document.querySelector("[data-notify-state]");
if (!hint) return;
if (!("Notification" in window)) {
hint.textContent = "This browser has no notifications to offer.";
} else if (Notification.permission === "denied") {
hint.textContent =
"Blocked for this site in your browser's settings, which is the only " +
"place that can be undone.";
} else if (on) {
hint.textContent = "On in this browser. Nothing is shown while you are looking at the page.";
} else {
hint.textContent = "Off in this browser.";
}
}
function scan() {
document.querySelectorAll("[data-notify-toggle]").forEach(paint);
}
function start() {
scan();
/* After the page has settled rather than during it: the offer is a dialog,
and one that appears while the shell is still being painted reads as an
error rather than as a question. */
setTimeout(offer, 1500);
}
document.addEventListener("DOMContentLoaded", start);
document.body && start();
document.addEventListener("htmx:afterSettle", scan);
})();
/*
Two number fields set together.
Image sizes come in pairs and nobody types 1024 and then 1536; they pick
"portrait". A button rather than a select of pairs, because a select would
have to enumerate every combination somebody might want and these are only
the common ones — the two boxes are still there and still authoritative.
Delegated and keyed on the attributes rather than on the image page's ids, so
the next screen with a paired number wants no new code.
*/
document.addEventListener("click", function (event) {
var preset = event.target.closest("[data-width][data-height]");
if (!preset) return;
var row = preset.closest("[data-size-presets]");
if (!row) return;
event.preventDefault();
var form = preset.closest("form");
if (!form) return;
var width = form.querySelector('[name="default_width"]');
var height = form.querySelector('[name="default_height"]');
if (width) width.value = preset.dataset.width;
if (height) height.value = preset.dataset.height;
});
/*
A toast asked for by the server.
Some routes answer 204 because there is nothing to swap, and still have
something to say -- answering a question that has already timed out, for
instance. `HX-Trigger: {"lembas:notify": {"message": …}}` is how they say it.
*/
document.addEventListener("lembas:notify", function (event) {
var detail = event.detail || {};
if (!detail.message || !window.lembas || !window.lembas.notify) return;
window.lembas.notify(detail.message, { kind: detail.kind || "" });
});
/*
Send becomes Stop while a reply is being written.
One button in the markup (see chat/_composer.html), retargeted here. The
composer and the streaming bubble are far apart in the document, so the link
between them is made at runtime: whenever the thread changes, look for a
message that is still streaming and point the button at it. A
MutationObserver rather than htmx events, because the bubble is replaced by
an SSE swap that does not always surface as one.
This used to build a second button and hide it with the `hidden` attribute,
which did nothing at all: `.btn` sets `display: inline-flex`, and that beats
the browser's `[hidden] { display: none }`. app.css now forces the attribute
to win, and there is only one button to get wrong.
*/
(function () {
"use strict";
function streamingMessage() {
var live = document.querySelector(".msg[sse-connect]");
if (!live) return null;
var id = live.id.replace(/^msg-/, "");
var chat = (live.getAttribute("sse-connect") || "").match(/\/api\/chats\/([^/]+)\//);
return chat ? { messageId: id, chatId: chat[1] } : null;
}
function sync() {
var button = document.querySelector("[data-composer-action]");
if (!button) return;
var active = streamingMessage();
button.dataset.composerAction = active ? "stop" : "send";
// As a submit button the form sends; as a plain button the click handler
// below stops. Nothing else distinguishes the two states.
button.type = active ? "button" : "submit";
button.setAttribute("aria-label", active ? "Stop generating" : "Send");
button.title = active ? "Stop generating" : "";
button.disabled = false;
}
document.addEventListener("click", function (event) {
var button = event.target.closest('[data-composer-action="stop"]');
if (!button) return;
event.preventDefault();
var target = streamingMessage();
if (!target) return;
// Disabled until the next sync, so a second click cannot fire a second
// request at a generation that is already stopping.
button.disabled = true;
fetch(
"/api/chats/" + target.chatId + "/messages/" + target.messageId + "/stop",
{ method: "POST", credentials: "same-origin" }
).catch(function () { button.disabled = false; });
});
function watch() {
var thread = document.getElementById("thread");
if (thread) {
new MutationObserver(sync).observe(thread, { childList: true, subtree: true });
}
sync();
}
document.addEventListener("DOMContentLoaded", watch);
document.body && document.body.addEventListener("htmx:afterSettle", sync);
})();
/*
Chat or Agent, on the new-chat composer.
Two radios rather than a checkbox because they are two kinds of conversation,
not a setting on one -- and the choice is permanent, so it should read as a
fork. Picking Agent reveals the connection and directory; picking Chat hides
them and sets the hidden `kind` back, so a form submitted either way carries
exactly what it means.
*/
(function () {
/* The last segment of a path, with trailing slashes ignored so `/srv/app/`
reads as `app` rather than as nothing. `/` is itself, since it has no name
of its own and "the root" is what somebody means by it. */
function baseName(path) {
var trimmed = String(path || "").replace(/\/+$/, "");
if (!trimmed) return "/";
return trimmed.slice(trimmed.lastIndexOf("/") + 1) || "/";
}
function wire(root) {
var kind = root.querySelector("#chat-kind");
var extra = root.querySelector("[data-agent-extra]");
var picker = root.querySelector('select[name="ssh_profile_id"]');
var dir = root.querySelector("[data-dir-value]");
var dirLabel = root.querySelector("[data-dir-label]");
if (!kind || !extra) return;
/* The directory is a hidden field plus a button, so the two have to be set
together or the button shows one path and the form submits another.
The button shows the directory's own name; the full path goes in the
tooltip and, of course, in the field that is submitted. A real project
path is long enough that showing it whole made the button eat the row and
squeeze the mode select beside it, and the leading directories are the
part nobody is reading -- what you check before sending is that you are
in `myproject` rather than `myproject-old`. */
function setDir(value) {
if (!dir) return;
dir.value = value || "";
if (dirLabel) dirLabel.textContent = value ? baseName(value) : "the login directory";
var button = dirLabel && dirLabel.closest("[data-dir-browse]");
if (button) button.title = value || "The connection's own login directory";
announce();
}
function sync() {
var chosen = root.querySelector('input[name="kind_choice"]:checked');
var agent = chosen && chosen.value === "agent";
kind.value = agent ? "agent" : "chat";
extra.hidden = !agent;
}
function profileDefault() {
var option = picker && picker.options[picker.selectedIndex];
return (option && option.dataset.dir) || "";
}
/* Which machine and which directory the panels should be looking at.
Dispatched rather than read, because nothing here owns the terminal or
the canvas -- and because assigning to a hidden field's `.value` fires no
event of its own, so `setDir` has to say so out loud. */
function announce() {
var chosen = kind && kind.value === "agent" ? (picker ? picker.value : "") : "";
document.dispatchEvent(new CustomEvent("lembas:agent-target", {
detail: { profileId: chosen, projectDir: dir ? dir.value : "" }
}));
}
root.addEventListener("change", function (event) {
if (event.target.name === "kind_choice") { sync(); announce(); }
// Following the profile's own directory is a convenience, not a rule:
// once someone has chosen their own it is left alone.
if (event.target === picker && dir && !dir.dataset.touched) {
setDir(profileDefault());
}
if (event.target === picker) announce();
});
root.addEventListener("click", function (event) {
if (!event.target.closest("[data-dir-browse]")) return;
event.preventDefault();
var profileId = picker ? picker.value : "";
if (!profileId) return;
window.lembas.chooseDirectory(profileId, dir.value, function (chosen) {
dir.dataset.touched = "1";
setDir(chosen);
});
});
sync();
/* Seeded from whichever profile the select is actually showing, not from
the first in the list -- an unverified first profile renders `disabled`,
so the two disagreed and the box offered a directory on a machine the
chat was not going to use. */
setDir(profileDefault());
announce();
}
/* The same directory picker, on a form that is not the composer.
Scoped to `[data-dir-field]`, which the composer's own markup does not
carry -- otherwise this and `wire()` above would both answer one click and
open two dialogs. The composer keeps its own handler because it has more to
do: it follows the selected profile's default directory until somebody
chooses their own, which only makes sense while a chat is being created.
A path is something you would rather find than spell, and the text box this
replaced was the one control on the folder page that asked somebody to
remember an absolute path on another machine. */
document.addEventListener("click", function (event) {
var field = event.target.closest("[data-dir-field]");
if (!field) return;
var value = field.querySelector("[data-dir-value]");
var label = field.querySelector("[data-dir-label]");
if (!value) return;
function show(path) {
value.value = path || "";
if (label) label.textContent = path || "the connection's own default";
}
if (event.target.closest("[data-dir-clear]")) {
event.preventDefault();
show("");
return;
}
if (!event.target.closest("[data-dir-browse]")) return;
event.preventDefault();
/* The connection this folder points at. Browsing needs one, and saying so
beats a dialog that opens onto nothing. */
var form = field.closest("form");
var picker = form && form.querySelector('select[name="ssh_profile_id"]');
var profileId = picker ? picker.value : "";
if (!profileId) {
window.lembas.notify("Choose a connection first — there is nothing to browse without one.");
return;
}
window.lembas.chooseDirectory(profileId, value.value, show);
});
function scan() {
document.querySelectorAll("[data-agent-picker]").forEach(function (el) {
if (!el.dataset.wired) { el.dataset.wired = "1"; wire(el); }
});
}
/* The two panel buttons, on the screen where the server cannot answer.
Both the canvas and the terminal need an agent chat on a chosen connection,
and before a chat exists both of those are radio buttons and a select in the
composer -- nothing the server has seen. It used to answer with
`profiles[0]`, so the buttons were offered on the new-chat screen whatever
the toggle said and whatever was selected, and pressing either opened a
panel that could not work.
So the markup renders them `hidden` carrying `data-agent-only`, and this
follows the event `wire()` already dispatches. Anything without that
attribute is left alone: on a chat that exists the server's answer is
complete and this must not second-guess it.
Closing a panel whose target has just gone is not tidiness. The panel is
still pointed at the old connection, and leaving it open would show one
machine's files under a heading naming another. */
document.addEventListener("lembas:agent-target", function (event) {
var ready = !!(event.detail && event.detail.profileId);
document.querySelectorAll("[data-agent-only]").forEach(function (button) {
button.hidden = !ready;
if (ready) return;
var selector = button.dataset.toggle || "";
var panel = selector && document.querySelector(selector);
if (panel && !panel.hidden && window.lembas && window.lembas.setPanel) {
window.lembas.setPanel(selector, false);
}
});
});
document.addEventListener("DOMContentLoaded", scan);
document.body && scan();
document.addEventListener("htmx:afterSettle", scan);
})();
/*
Tabs remember their scroll position, and that made short panels look empty.
A tab is a radio and a panel is shown by CSS, so switching one changes nothing
about `.tabs__body` -- which is the element that scrolls. Read half way down
the long Tools panel on /admin/prompts, click Context, and the container keeps
a scrollTop the new panel is not tall enough to fill: the browser clamps it to
that panel's bottom, and what lands on screen is the end of it above a screen
of nothing. It reads as a page that failed to load, and the way out is to
scroll up before scrolling down.
Nothing in CSS can reset a scroll position, so this is the smallest amount of
JavaScript that fixes it: on a tab change, put the container that actually
scrolls back to the top. Delegated and keyed on the class rather than on any
one page, because every tabbed screen here has the same problem.
Which container that is depends on where the tabs are, and assuming it was
always `.tabs__body` is why this did nothing at all on /admin/prompts for the
whole life of the fix. `.tabs__body` scrolls only when `.tabs` is a flex child
of something bounded -- true on the settings page, false under the admin
layout, where the scroller is the `.admin-scroll` above it and `.tabs__body`
has `height: auto`. Setting `scrollTop = 0` on an element that does not scroll
is a silent no-op, which is exactly the kind of failure that survives review.
So: walk up from the bar and reset the first ancestor that can scroll. That is
correct on both shapes without knowing which one it is looking at.
*/
(function () {
function scroller(node) {
for (var el = node; el && el !== document.body; el = el.parentElement) {
var overflow = getComputedStyle(el).overflowY;
if ((overflow === "auto" || overflow === "scroll") && el.scrollHeight > el.clientHeight) {
return el;
}
}
return null;
}
document.addEventListener("change", function (event) {
var radio = event.target;
if (!radio || radio.type !== "radio") return;
var bar = radio.closest && radio.closest(".tabs__bar");
if (!bar) return;
/* The body first, because on the settings page it is the scroller and is
also the thing whose *content* changed; then whatever encloses the tabs.
Both, not either: on the admin layout the body may still hold a scrolled
inner panel while the page itself is what the reader is lost in. */
var body = bar.parentElement && bar.parentElement.querySelector(".tabs__body");
if (body) body.scrollTop = 0;
/* And where the page itself is the scroller, put the bar back at the top of
it -- not the page at zero. There is content above the tabs on
/admin/prompts and the reader has just asked to look at a tab, so the tab
bar is where they want to be.
This has to happen *after* the panel has swapped, which it has: :checked
applies before `change` fires. That order is the whole failure -- the
browser scrolls the focused radio into view first, then the shorter panel
shrinks the document and scrollTop is clamped to the new maximum, which
for a short panel is somewhere below everything. */
var outer = scroller(bar);
if (outer && outer !== body) bar.scrollIntoView({ block: "start" });
});
})();
/*
Opening a file into the canvas, by looking rather than by spelling.
The button cannot carry an `hx-post` because the path is not known until the
dialog closes -- so this posts it once it is, through htmx's own `ajax` so the
response lands in the panel exactly as every other canvas action's does. Doing
it with `fetch` would mean parsing and swapping the fragment by hand, and then
there would be two ways the canvas gets replaced.
The key is `agent:<path>`, which is the same key the model's own reads produce
-- so a file opened here and the same file opened by a tool call are one tab
rather than two spellings of it. That is `canvas.path_key`'s whole job, and it
is why the prefix is added here rather than asked of the reader.
*/
(function () {
document.addEventListener("click", function (event) {
var button = event.target.closest && event.target.closest("[data-canvas-open]");
if (!button) return;
event.preventDefault();
var profile = button.dataset.profile;
var chat = button.dataset.chat;
if (!profile || !chat) {
window.lembas.notify("This chat is not pointed at a machine, so there is nothing to browse.");
return;
}
window.lembas.chooseFile(profile, button.dataset.dir || "", function (path) {
if (!path) return;
window.htmx.ajax("POST", "/api/chats/" + encodeURIComponent(chat) + "/canvas/tabs", {
target: "#canvas-inner",
swap: "innerHTML",
values: { key: "agent:" + path }
});
});
});
})();