Inside a chat the picker sits mid-bar with the panel buttons to its right, and its menu opened from the picker's right edge -- at 390px it spanned x = -132..226, cutting every model's name off. Below 48rem the bar is now the containing block and the menu is pinned between its edges (capped at 24rem). Opening it on a touchscreen no longer focuses the filter, which raised the keyboard over half the list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1177 lines
46 KiB
JavaScript
1177 lines
46 KiB
JavaScript
/*
|
||
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, "");
|
||
}
|
||
// Not on a touchscreen: focusing a text field there raises the keyboard,
|
||
// which covers half the list the finger came to choose from. The filter
|
||
// is one tap away for whoever wants it.
|
||
if (filter && !window.matchMedia("(hover: none)").matches) {
|
||
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 }
|
||
});
|
||
});
|
||
});
|
||
})();
|