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>
1172 lines
45 KiB
JavaScript
1172 lines
45 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, "");
|
||
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 }
|
||
});
|
||
});
|
||
});
|
||
})();
|