Files
LLeMbas/src/lembas/web/static/js/app.js
T
Jaroslav Beneš 78e5717f77 An instance that can be somebody else's
A name, a tagline, a logo, a favicon and the launcher icons derived from it; the
Middle-earth strings as data; themes as token sets; and a stylesheet for what
none of that reaches. All four are on one page, in one settings group.

The snapshot is a Jinja global over a process-level cache, because render() has
no session and four render paths never reach it at all -- the sign-in page, the
error pages, the offline page and the SSE fragments. A context value would have
had to be threaded through every one and would still have missed those. It being
a global is also what lets mark() branch on an uploaded logo without any of its
six call sites learning about branding; the macro that renders the sidebar link
is called brandlink now, because a macro imported as `brand` shadows the global
for the whole template and took out every page at once.

Defaults in code and overrides in the database, as the prompt fragments do, with
one difference stated in the module: an empty fragment means off, an empty
flavour string means the shipped wording. And blanked rather than dropped --
settings_store.update merges, so an omitted key leaves what was stored last time
and "I typed the default back in" would store something different from "I changed
nothing".

A custom theme sets a handful of tokens and inherits the rest, and the
inheritance is a CSS fact: tokens.css matches [data-base="shire"] as well as
[data-theme="shire"], so a custom light theme lands on parchment rather than four
light colours on near-black. Values are validated on read rather than on save,
because a theme written straight into the settings table still has to produce a
stylesheet that parses -- a `}` in a value ends the rule and silently breaks
every rule after it. The soft variants are derived from the accent, or a changed
accent leaves focus rings in the old hue and reads as half-working.

/branding.css is a route, not an inline block: an external stylesheet has no HTML
context to escape from. The link carries a content hash, so a save is not left to
the browser's cache, and it is deliberately outside the service worker's precache
list, which is versioned by the release.

The instance name moved off /admin/general rather than being duplicated there.
An upgrade keeps it: the general row is read as a seed exactly while the branding
row has never mentioned the name, which is `key in row` and not `row[key] is
truthy` -- the two read alike would resurrect the old name underneath a cleared
one.

The theme list stops being a hard-coded pair in five places. Every failure mode
in that area is silent, so it is driven under a DOM stub as well as tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:42:25 +02:00

1001 lines
40 KiB
JavaScript

/*
Client-side behaviour.
Everything here is progressive: the application is server-rendered and works
without this file, apart from the streaming reply, which is htmx's SSE
extension rather than anything hand-written below.
*/
(function () {
"use strict";
var THEME_KEY = "lembas-theme";
/* --- Theme -------------------------------------------------------------
Stored locally so the choice applies instantly and survives being signed
out, and mirrored to the server so it follows the user to another device.
The server call is best-effort: a failure must not undo the local switch.
The list used to be a literal pair here, and in four other places. It comes
from `data-themes` on <html> now -- "id:base" pairs, space separated --
because an administrator can define one, and a hard-coded pair would refuse
it silently: applyTheme would return, the button would do nothing, and
nothing anywhere would say why. */
function themes() {
var raw = document.documentElement.dataset.themes || "moria:moria shire:shire";
var map = {};
raw.split(/\s+/).forEach(function (entry) {
var parts = entry.split(":");
if (parts[0]) map[parts[0]] = parts[1] || "moria";
});
return map;
}
function themeNames() {
return Object.keys(themes());
}
function currentTheme() {
return document.documentElement.dataset.theme || themeNames()[0];
}
function applyTheme(name) {
var known = themes();
if (!Object.prototype.hasOwnProperty.call(known, name)) return;
document.documentElement.dataset.theme = name;
/* Both attributes, always. `data-base` is what makes a custom theme inherit
its built-in palette -- tokens.css matches it as well as `data-theme` --
so setting only the first leaves a custom light theme's four colours on
Moria's near-black surfaces. */
document.documentElement.dataset.base = known[name];
try {
localStorage.setItem(THEME_KEY, name);
} catch (e) { /* private mode */ }
/* Installed, the browser's own chrome is the application's chrome, so it
has to follow the theme too. Read from the stylesheet rather than
repeating the hex here: tokens.css is the one place colours live. */
var meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
var bg = getComputedStyle(document.documentElement)
.getPropertyValue("--bg").trim();
if (bg) meta.setAttribute("content", bg);
}
/* The toggle names where it is going, not where it is. With more than two
themes "the next one" is the honest description, because naming it would
mean carrying every label into the browser for a label nobody reads
twice. */
document.querySelectorAll("[data-theme-toggle]").forEach(function (el) {
el.setAttribute("aria-label", known[name] === "moria" ? "Switch to the light theme"
: "Switch to the dark theme");
});
/* For anything holding colours as values rather than reading them from a
variable. The terminal is the only such thing: xterm copies its palette
at construction, so switching to Shire would otherwise leave a black
rectangle in a light interface. */
document.dispatchEvent(new CustomEvent("lembas:theme", { detail: { theme: name } }));
if (document.body.dataset.authenticated === "true") {
fetch("/api/preferences/theme", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme: name })
}).catch(function () { /* preference is already applied locally */ });
}
}
/* Round the list rather than between two names. With only the built-in pair
this is exactly what it always did; with a third defined it reaches it,
which a hard-coded flip never could. */
function toggleTheme() {
var names = themeNames();
var at = names.indexOf(currentTheme());
applyTheme(names[(at + 1) % names.length] || names[0]);
}
/* --- Textarea autosize -------------------------------------------------
Grows the composer with its content up to a cap, after which it scrolls. */
function autosize(el) {
if (!el) return;
var max = parseInt(el.dataset.maxHeight || "320", 10);
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, max) + "px";
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
}
/* --- Copy --------------------------------------------------------------
Falls back to a hidden textarea because navigator.clipboard is unavailable
on pages served over plain http, which self-hosted installs often are. */
function copyText(text, trigger) {
function done() {
if (!trigger) return;
var original = trigger.getAttribute("aria-label");
trigger.classList.add("is-copied");
trigger.setAttribute("aria-label", "Copied");
setTimeout(function () {
trigger.classList.remove("is-copied");
if (original) trigger.setAttribute("aria-label", original);
}, 1400);
}
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(done).catch(function () {});
return;
}
var scratch = document.createElement("textarea");
scratch.value = text;
scratch.setAttribute("readonly", "");
scratch.style.position = "fixed";
scratch.style.opacity = "0";
document.body.appendChild(scratch);
scratch.select();
try { document.execCommand("copy"); done(); } catch (e) { /* nothing to do */ }
document.body.removeChild(scratch);
}
/* --- Thread scrolling --------------------------------------------------
Only auto-scrolls when the reader is already near the bottom, so scrolling
up to re-read something is not yanked away by an incoming token. */
var STICK_THRESHOLD = 120;
function isNearBottom(el) {
return el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
}
/* Whether the view is following the reply. Distance to the bottom used to be
the whole test, and it very nearly is -- but somebody near the bottom who
opens a tool block is *reading*, and the next frame eighty milliseconds
later dragged them straight back down again. At twelve frames a second that
reads as a block that will not open at all.
So opening one turns following off, and returning to the bottom turns it
back on. `stick` is the gate; `isNearBottom` is what maintains it. */
var stick = true;
/* Where scrollThread last put it, so a scroll event can be told apart from a
scroll the reader did. Without this the programmatic scroll re-arms `stick`
on its own and nothing ever unsticks. */
var placed = -1;
function scrollThread(force) {
var thread = document.getElementById("thread-scroll");
if (!thread) return;
if (force) stick = true;
if (stick) {
thread.scrollTop = thread.scrollHeight;
placed = thread.scrollTop;
}
}
document.addEventListener(
"scroll",
function (event) {
var thread = event.target;
if (!thread || thread.id !== "thread-scroll") return;
if (thread.scrollTop === placed) return;
stick = isNearBottom(thread);
},
true
);
/* --- Reading backwards -------------------------------------------------
The mirror of `scrollThread`. Messages pages in older turns above the ones
on screen, and *prepending* moves everything down by the height of what
arrived -- so without this the reader is dragged up the page the instant
the sentinel fires, which reads as a browser bug rather than as a feature.
Height is recorded before the swap and the difference added back after, so
whatever was under the reader's eye stays there. `overflow-anchor` is not
reliable across an htmx swap, and `stick` deliberately is not touched:
reading history is not following a reply, and re-arming it here would jump
to the bottom the moment the next page landed. */
var heightBefore = -1;
document.body.addEventListener("htmx:beforeSwap", function (event) {
var el = event.target;
if (!el || !el.classList || !el.classList.contains("history-sentinel")) return;
var thread = document.getElementById("thread-scroll");
heightBefore = thread ? thread.scrollHeight : -1;
});
document.body.addEventListener("htmx:afterSwap", function (event) {
if (heightBefore < 0) return;
var thread = document.getElementById("thread-scroll");
if (thread) {
thread.scrollTop += thread.scrollHeight - heightBefore;
placed = thread.scrollTop;
}
heightBefore = -1;
});
/* `toggle` does not bubble, so this has to be registered in the CAPTURE phase.
Without the third argument the listener is never called and the whole thing
is silently dead in every browser -- the same shape of failure as a trigger
bound where the event does not go. There is a test on it. */
document.addEventListener(
"toggle",
function (event) {
var el = event.target;
if (!el || el.tagName !== "DETAILS" || !el.open) return;
if (el.closest && el.closest("#thread-scroll")) stick = false;
},
true
);
/* --- Attachments -------------------------------------------------------
Files are uploaded one at a time as soon as they are chosen, dropped or
pasted, rather than all at once when the message is sent. The chip (or the
rejection) then appears immediately, and a large file cannot make the send
button appear to hang. */
function uploadFiles(fileList) {
var input = document.getElementById("file-input");
var target = document.getElementById("attachments");
if (!input || !target || !fileList || !fileList.length) return;
var url = input.dataset.uploadUrl;
Array.prototype.forEach.call(fileList, function (file) {
var body = new FormData();
body.append("file", file, file.name);
fetch(url, { method: "POST", body: body, credentials: "same-origin" })
.then(function (response) { return response.text(); })
.then(function (html) {
target.insertAdjacentHTML("beforeend", html);
// The chip's remove button is htmx-driven, so the new markup has to
// be announced or its attributes are inert.
if (window.htmx) window.htmx.process(target.lastElementChild);
})
.catch(function () {
target.insertAdjacentHTML(
"beforeend",
'<div class="attach-chip attach-chip--error">' +
'<span class="attach-chip__body">' +
'<span class="attach-chip__name"></span>' +
'<span class="attach-chip__warning">Upload failed.</span></span></div>'
);
// Set as text, never as HTML: the filename comes from the user.
target.lastElementChild.querySelector(".attach-chip__name").textContent =
file.name;
});
});
}
/* --- Attaching something that is not a file ----------------------------
The composer's menu offers four things; two of them are the file picker
with a different filter, and two need a round trip. Both of those post a
form and get a chip back, exactly like an upload, so the composer does not
have to know where a chip came from. */
function chipTarget() {
return document.getElementById("attachments");
}
function chatId() {
var input = document.getElementById("file-input");
var url = (input && input.dataset.uploadUrl) || "";
var match = url.match(/chat_id=([^&]+)/);
return match ? decodeURIComponent(match[1]) : "";
}
function postForChip(url, body) {
var target = chipTarget();
if (!target) return Promise.resolve();
return fetch(url, { method: "POST", body: body, credentials: "same-origin" })
.then(function (response) { return response.text(); })
.then(function (html) {
target.insertAdjacentHTML("beforeend", html);
if (window.htmx) window.htmx.process(target.lastElementChild);
});
}
function attachLink() {
if (!window.lembas || !window.lembas.prompt) return;
window.lembas.prompt({
title: "Attach a web page",
message: "The page is fetched now and its text attached, so it will not " +
"change between writing this and sending it.",
placeholder: "https://example.com/article",
confirmLabel: "Fetch",
}).then(function (url) {
if (!url || !url.trim()) return;
var body = new FormData();
body.append("url", url.trim());
body.append("chat_id", chatId());
return postForChip("/api/files/link", body);
});
}
/* The knowledge dialog re-queries the server as you type rather than
filtering in the browser: the library is searched with FTS, which is what
makes it work at five hundred documents instead of five. */
function attachKnowledge() {
var dialog = document.createElement("dialog");
dialog.className = "dialog dialog--wide";
dialog.innerHTML =
'<div class="dialog__form">' +
'<h2 class="dialog__title">Attach from your library</h2>' +
'<input class="input" type="search" placeholder="Search your documents…" ' +
'aria-label="Search your documents">' +
'<div class="dialog__results"></div>' +
'<div class="dialog__actions"><button class="btn" type="button">Close</button></div>' +
"</div>";
document.body.appendChild(dialog);
var search = dialog.querySelector("input");
var results = dialog.querySelector(".dialog__results");
var close = dialog.querySelector("button");
function load(query) {
fetch("/api/files/knowledge-picker?q=" + encodeURIComponent(query || ""), {
credentials: "same-origin",
})
.then(function (response) { return response.text(); })
.then(function (html) { results.innerHTML = html; })
.catch(function () { results.textContent = "Could not load your library."; });
}
var pending = null;
search.addEventListener("input", function () {
clearTimeout(pending);
pending = setTimeout(function () { load(search.value); }, 200);
});
results.addEventListener("click", function (event) {
var option = event.target.closest("[data-attach-knowledge]");
if (!option) return;
var body = new FormData();
body.append("document_id", option.dataset.attachKnowledge);
body.append("chat_id", chatId());
postForChip("/api/files/from-knowledge", body);
finish();
});
function finish() {
dialog.close();
setTimeout(function () { dialog.remove(); }, 200);
}
close.addEventListener("click", finish);
dialog.addEventListener("cancel", function (event) {
event.preventDefault();
finish();
});
dialog.addEventListener("click", function (event) {
if (event.target === dialog) finish();
});
dialog.showModal();
load("");
search.focus();
}
/* --- Choosing a directory on the far side --------------------------------
Shaped like attachKnowledge above, with one difference: a click walks
deeper rather than finishing, and finishing is its own button. The path
that gets submitted is the directory you are *standing in*, not the last
row you pressed, so choosing the directory you are already looking at
needs no click at all. */
function chooseDirectory(profileId, current, onPick) {
var dialog = document.createElement("dialog");
dialog.className = "dialog dialog--wide";
dialog.innerHTML =
'<div class="dialog__form">' +
'<h2 class="dialog__title">Project directory</h2>' +
'<p class="dialog__note">Where this chat starts, and what a relative path ' +
"is measured from. You can walk anywhere the account can reach.</p>" +
'<div class="dialog__results"></div>' +
'<input class="input input--mono" type="text" spellcheck="false" ' +
'aria-label="Path" placeholder="/project">' +
'<div class="dialog__actions">' +
'<button class="btn" type="button" data-dir-cancel>Cancel</button>' +
'<button class="btn btn--primary" type="button" data-dir-use>Use this directory</button>' +
"</div></div>";
document.body.appendChild(dialog);
var results = dialog.querySelector(".dialog__results");
var typed = dialog.querySelector("input");
var here = current || "";
function load(path) {
results.setAttribute("aria-busy", "true");
fetch(
"/api/agents/" + encodeURIComponent(profileId) +
"/browse?path=" + encodeURIComponent(path || ""),
{ credentials: "same-origin" }
)
.then(function (response) { return response.text(); })
.then(function (html) {
results.innerHTML = html;
var box = results.querySelector("#dir-results");
/* The server decides where we ended up -- it resolved the empty
path to the profile's own default -- so the typed field follows
it rather than the other way round. */
if (box) { here = box.dataset.here || path || ""; typed.value = here; }
results.removeAttribute("aria-busy");
})
.catch(function () {
results.textContent = "Could not reach that machine.";
results.removeAttribute("aria-busy");
});
}
results.addEventListener("click", function (event) {
var row = event.target.closest("[data-dir-open]");
if (!row) return;
load(row.dataset.dirOpen);
});
/* Typing a path you already know beats clicking to it, so the field is a
first-class way in and not only a display of where you are. */
typed.addEventListener("keydown", function (event) {
if (event.key !== "Enter") return;
event.preventDefault();
load(typed.value.trim());
});
function finish(chosen) {
if (chosen !== undefined) onPick(chosen);
dialog.close();
setTimeout(function () { dialog.remove(); }, 200);
}
dialog.querySelector("[data-dir-use]").addEventListener("click", function () {
finish(typed.value.trim() || here);
});
dialog.querySelector("[data-dir-cancel]").addEventListener("click", function () {
finish();
});
dialog.addEventListener("cancel", function (event) {
event.preventDefault();
finish();
});
dialog.addEventListener("click", function (event) {
if (event.target === dialog) finish();
});
dialog.showModal();
load(current || "");
}
/* --- Choosing a file on the far side -------------------------------------
The same walk as chooseDirectory, finishing on a file rather than on a
button. Canvas used to ask for a typed path, which is the one thing in this
application that expected somebody to remember an absolute path on another
machine -- the same complaint the folder page's directory box answered.
A separate function rather than a flag on the one above, because almost
everything differs: what a click does, what finishes it, whether there is a
"use this" button at all, and what the dialog is called. What they share is
the listing, and that is shared where it matters -- one fragment on the
server, asked for with `pick=file`. */
function chooseFile(profileId, current, onPick) {
var dialog = document.createElement("dialog");
dialog.className = "dialog dialog--wide";
dialog.innerHTML =
'<div class="dialog__form">' +
'<h2 class="dialog__title">Open a file</h2>' +
'<p class="dialog__note">Pick a file to open in the canvas. Folders walk ' +
"deeper; you can also type a path and press Enter.</p>" +
'<div class="dialog__results"></div>' +
'<input class="input input--mono" type="text" spellcheck="false" ' +
'aria-label="Path" placeholder="/project/src/main.py">' +
'<div class="dialog__actions">' +
'<button class="btn" type="button" data-file-cancel>Cancel</button>' +
"</div></div>";
document.body.appendChild(dialog);
var results = dialog.querySelector(".dialog__results");
var typed = dialog.querySelector("input");
var here = current || "";
function load(path) {
results.setAttribute("aria-busy", "true");
fetch(
"/api/agents/" + encodeURIComponent(profileId) +
"/browse?pick=file&path=" + encodeURIComponent(path || ""),
{ credentials: "same-origin" }
)
.then(function (response) { return response.text(); })
.then(function (html) {
results.innerHTML = html;
var box = results.querySelector("#dir-results");
if (box) { here = box.dataset.here || path || ""; typed.value = here; }
results.removeAttribute("aria-busy");
})
.catch(function () {
results.textContent = "Could not reach that machine.";
results.removeAttribute("aria-busy");
});
}
function finish(chosen) {
if (chosen !== undefined) onPick(chosen);
dialog.close();
setTimeout(function () { dialog.remove(); }, 200);
}
results.addEventListener("click", function (event) {
/* A file finishes; a folder is a step. Checked in that order because a
row is one or the other and the file case is what this dialog is for. */
var file = event.target.closest("[data-file-open]");
if (file) { finish(file.dataset.fileOpen); return; }
var row = event.target.closest("[data-dir-open]");
if (row) load(row.dataset.dirOpen);
});
/* Enter opens what was typed if it looks like a file, and walks into it
otherwise. There is no way to tell from here which it is, so the server
decides: a path that lists is a directory and the listing comes back; one
that does not is taken as a file. Cheaper than a second endpoint asking
"what is this", and wrong only for a directory that cannot be read --
which the canvas then reports in its own words. */
typed.addEventListener("keydown", function (event) {
if (event.key !== "Enter") return;
event.preventDefault();
var value = typed.value.trim();
if (value && value !== here) { finish(value); return; }
load(value);
});
dialog.querySelector("[data-file-cancel]").addEventListener("click", function () {
finish();
});
dialog.addEventListener("cancel", function (event) {
event.preventDefault();
finish();
});
dialog.addEventListener("click", function (event) {
if (event.target === dialog) finish();
});
dialog.showModal();
load(current || "");
}
document.addEventListener("click", function (event) {
var choice = event.target.closest("[data-attach]");
if (!choice) return;
event.preventDefault();
var kind = choice.dataset.attach;
if (kind === "file") document.getElementById("file-input").click();
else if (kind === "image") document.getElementById("image-input").click();
else if (kind === "link") attachLink();
else if (kind === "knowledge") attachKnowledge();
});
function setupDropzone() {
var zone = document.querySelector("[data-dropzone]");
if (!zone) return;
/* dragenter/dragleave fire for every child element the pointer crosses, so
a plain toggle flickers. Counting entries and exits is the standard fix. */
var depth = 0;
function hasFiles(event) {
return event.dataTransfer && Array.prototype.indexOf.call(
event.dataTransfer.types || [], "Files"
) !== -1;
}
zone.addEventListener("dragenter", function (event) {
if (!hasFiles(event)) return;
event.preventDefault();
depth += 1;
zone.classList.add("is-dropping");
});
zone.addEventListener("dragover", function (event) {
if (hasFiles(event)) event.preventDefault();
});
zone.addEventListener("dragleave", function () {
depth = Math.max(0, depth - 1);
if (depth === 0) zone.classList.remove("is-dropping");
});
zone.addEventListener("drop", function (event) {
if (!hasFiles(event)) return;
event.preventDefault();
depth = 0;
zone.classList.remove("is-dropping");
uploadFiles(event.dataTransfer.files);
});
/* Pasting a screenshot straight into the composer. Only files are taken;
pasted text must still behave as text. */
document.addEventListener("paste", function (event) {
var composer = event.target.closest("[data-composer-input]");
if (!composer || !event.clipboardData) return;
var files = Array.prototype.filter.call(
event.clipboardData.files || [], function (f) { return f && f.size; }
);
if (!files.length) return;
event.preventDefault();
uploadFiles(files);
});
}
/* --- Installing as an app ----------------------------------------------
Chromium fires beforeinstallprompt when it decides the app is installable
and lets the page defer the prompt. The event is the only handle on it, so
it is kept; there is no way to ask later whether one is available.
Nothing appears unless the browser offers it. Firefox and desktop Safari
never fire the event, and there is no useful button to show in their
place -- an "Install" that does nothing is worse than none. */
var installPrompt = null;
function revealInstall(show) {
document.querySelectorAll("[data-install-app]").forEach(function (el) {
el.hidden = !show;
});
}
function promptInstall() {
if (!installPrompt) return;
installPrompt.prompt();
installPrompt.userChoice.then(function () {
// A prompt is single-use, accepted or dismissed.
installPrompt = null;
revealInstall(false);
});
}
window.addEventListener("beforeinstallprompt", function (event) {
event.preventDefault();
installPrompt = event;
revealInstall(true);
});
window.addEventListener("appinstalled", function () {
installPrompt = null;
revealInstall(false);
});
/* --- Panels ------------------------------------------------------------- */
/* A panel can be opened or closed by more than one control -- the button in
the topbar and the panel's own Close -- and it can now also be closed by
something nobody clicked, because two panels sharing the right-hand side
of the screen must not both be open. So the state is applied to the panel
and then *every* toggle pointing at it is brought in line. Setting
aria-expanded on the clicked button alone left the other one lying. */
function syncToggles(selector, open) {
var toggles = document.querySelectorAll('[data-toggle="' + selector + '"]');
for (var i = 0; i < toggles.length; i++) {
toggles[i].setAttribute("aria-expanded", open ? "true" : "false");
toggles[i].classList.toggle("is-active", open);
}
}
function setPanel(selector, open, group) {
var panel = document.querySelector(selector);
if (!panel) return;
/* One at a time down the right-hand side. Not only a narrow-screen
concern: a 1280px window with the sidebar, the inspector and the
terminal all open leaves the conversation about seventy pixels wide. */
if (open && group) {
var others = document.querySelectorAll('[data-toggle-group="' + group + '"]');
for (var i = 0; i < others.length; i++) {
var other = others[i].dataset.toggle;
if (other && other !== selector) setPanel(other, false);
}
}
panel.toggleAttribute("hidden", !open);
syncToggles(selector, open);
/* What a panel needs to know it is visible. The terminal listens for this:
xterm cannot measure itself inside a hidden element, so it has to be
told rather than left to discover. */
panel.dispatchEvent(
new CustomEvent("lembas:toggle", { bubbles: true, detail: { open: open } })
);
}
/* --- Dragging a panel wider ---------------------------------------------
Generic rather than terminal-specific: the inspector and the sidebar want
the same handle, and a second copy of this is how two panels end up
resizing differently.
The width lands on a CSS variable on <html> rather than on the panel, so
the ≤64rem overlay rule -- which clamps it with min() -- keeps working
without knowing anything about dragging. Persisted the way the theme is:
localStorage for this tab, best-effort POST for the next device. */
var RESIZE_KEY = "lembas-panel-widths";
function storedWidths() {
try {
return JSON.parse(localStorage.getItem(RESIZE_KEY) || "{}") || {};
} catch (e) {
return {};
}
}
function applyWidths() {
var widths = storedWidths();
Object.keys(widths).forEach(function (name) {
document.documentElement.style.setProperty(name, widths[name] + "px");
});
}
function rememberWidth(name, pixels) {
var widths = storedWidths();
widths[name] = Math.round(pixels);
try {
localStorage.setItem(RESIZE_KEY, JSON.stringify(widths));
} catch (e) { /* private mode */ }
if (document.body.dataset.authenticated === "true") {
fetch("/api/preferences/layout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(widths)
}).catch(function () { /* already applied locally */ });
}
}
function setupResize() {
document.addEventListener("pointerdown", function (event) {
var handle = event.target.closest("[data-resize]");
if (!handle || event.button !== 0) return;
var panel = handle.closest("[data-resize-target]") || handle.parentElement;
if (!panel) return;
var name = handle.dataset.resize;
var min = parseFloat(handle.dataset.resizeMin || "320");
var startX = event.clientX;
var startWidth = panel.getBoundingClientRect().width;
var frame = null;
var pending = startWidth;
event.preventDefault();
handle.setPointerCapture(event.pointerId);
document.body.classList.add("is-resizing");
function move(moveEvent) {
/* The handle is on the panel's *left* edge and the panel is on the
right of the shell, so dragging left makes it wider. */
var max = Math.max(min, window.innerWidth - 360);
pending = Math.min(Math.max(startWidth - (moveEvent.clientX - startX), min), max);
/* Coalesced to a frame: the ResizeObserver on the panel calls xterm's
fit() and sends a resize frame up the socket, and doing that once
per pointermove is a frame per pixel of drag. */
if (frame) return;
frame = requestAnimationFrame(function () {
frame = null;
document.documentElement.style.setProperty(name, pending + "px");
});
}
function stop() {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", stop);
handle.removeEventListener("pointercancel", stop);
document.body.classList.remove("is-resizing");
if (frame) cancelAnimationFrame(frame);
document.documentElement.style.setProperty(name, pending + "px");
rememberWidth(name, pending);
}
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", stop);
handle.addEventListener("pointercancel", stop);
});
/* A keyboard has to be able to do this too, or the panel is only resizable
with a mouse and the handle is a focus trap that does nothing. */
document.addEventListener("keydown", function (event) {
var handle = event.target.closest("[data-resize]");
if (!handle) return;
var step = event.key === "ArrowLeft" ? 32 : event.key === "ArrowRight" ? -32 : 0;
if (!step) return;
event.preventDefault();
var panel = handle.closest("[data-resize-target]") || handle.parentElement;
var name = handle.dataset.resize;
var min = parseFloat(handle.dataset.resizeMin || "320");
var max = Math.max(min, window.innerWidth - 360);
var width = Math.min(Math.max(panel.getBoundingClientRect().width + step, min), max);
document.documentElement.style.setProperty(name, width + "px");
rememberWidth(name, width);
});
}
window.lembas = {
setPanel: setPanel,
applyTheme: applyTheme,
toggleTheme: toggleTheme,
copyText: copyText,
scrollThread: scrollThread,
autosize: autosize,
uploadFiles: uploadFiles,
chooseDirectory: chooseDirectory,
chooseFile: chooseFile,
promptInstall: promptInstall
};
/* --- Wiring ------------------------------------------------------------ */
document.addEventListener("click", function (event) {
var toggle = event.target.closest("[data-theme-toggle]");
if (toggle) {
event.preventDefault();
toggleTheme();
return;
}
var copy = event.target.closest("[data-copy]");
if (copy) {
event.preventDefault();
var source = document.getElementById(copy.dataset.copy);
if (source) copyText(source.textContent.trim(), copy);
return;
}
/* A suggestion card sends its prompt. One click, one reply -- filling the
box and waiting for Enter makes the card a form to review rather than a
thing to press. The built-in prompts are written to work sent cold: each
asks for what it needs, so the answer is a question back rather than a
guess at material nobody has given yet. */
var suggestion = event.target.closest("[data-suggestion]");
if (suggestion) {
event.preventDefault();
var input = document.querySelector("[data-composer-input]");
if (!input) return;
input.value = suggestion.dataset.suggestion;
autosize(input);
if (window.lembas.paintComposer) window.lembas.paintComposer();
var form = input.closest("form");
/* requestSubmit, not submit(): it fires the submit event, which is what
htmx is listening for. Same call the Enter key makes. */
if (form) form.requestSubmit();
return;
}
/* Show/hide a panel by selector, so templates do not each carry their own
inline toggle script. */
var toggle = event.target.closest("[data-toggle]");
if (toggle) {
event.preventDefault();
var panel = document.querySelector(toggle.dataset.toggle);
if (!panel) return;
setPanel(toggle.dataset.toggle, panel.hasAttribute("hidden"), toggle.dataset.toggleGroup);
}
});
document.addEventListener("input", function (event) {
if (event.target.matches("[data-autosize]")) autosize(event.target);
});
/* A "select all" box driving every checkbox inside a container. Scoped to a
selector rather than the whole page, so a list can carry more than one. */
document.addEventListener("change", function (event) {
var master = event.target.closest("[data-select-all]");
if (!master) return;
var scope = document.querySelector(master.dataset.selectAll);
if (!scope) return;
scope.querySelectorAll('input[type="checkbox"]').forEach(function (box) {
box.checked = master.checked;
});
});
/* Enter sends, Shift+Enter inserts a newline -- the convention every chat
application uses. Left alone on touch devices, where there is no easy
Shift and Enter should mean "new line". */
document.addEventListener("keydown", function (event) {
if (event.key !== "Enter" || event.shiftKey) return;
/* The Enter that commits an IME composition is not the Enter that sends.
Typing Japanese or Chinese, every accepted candidate would otherwise
post the half-written message. */
if (event.isComposing || event.keyCode === 229) return;
var composer = event.target.closest("[data-composer-input]");
if (!composer) return;
if (window.matchMedia("(pointer: coarse)").matches) return;
event.preventDefault();
var form = composer.closest("form");
if (form && composer.value.trim()) form.requestSubmit();
});
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll("[data-autosize]").forEach(autosize);
scrollThread(true);
applyTheme(currentTheme());
setupDropzone();
setupResize();
});
/* Before first paint rather than on DOMContentLoaded, so a panel that was
dragged wider does not open at its default and jump. */
applyWidths();
/* After any htmx swap: re-measure the composer and follow new content. */
document.body.addEventListener("htmx:afterSwap", function () {
document.querySelectorAll("[data-autosize]").forEach(autosize);
scrollThread(false);
});
/* --- The transcript tail -----------------------------------------------
A reply can begin without a request from this page: a background job
finishing wakes the chat server-side. There is no chat-level channel to
hear about it on -- the only stream is per-message, and it is opened by a
bubble this page has not got. So `#thread-tail` polls, and this is where it
is told what the page already holds.
The cursor is read from the DOM rather than from a variable rendered into
the page, because the DOM is the honest answer to that question. Every path
that appends a bubble moves it -- the composer's own POST, the `done`
frame's out-of-band swaps, the last poll -- and a variable would have to be
updated by each of them, correctly, forever.
`htmx:configRequest` and not `hx-vals="js:…"`: two of the three things here
are *cancellations*, which `hx-vals` cannot express, and splitting the read
from the cancellations would put one decision in two files. (It is also the
only string htmx would ever be handed to evaluate in this project, and it
would die silently under a CSP.) */
document.body.addEventListener("htmx:configRequest", function (event) {
var elt = (event.detail && event.detail.elt) || event.target;
if (!elt || elt.id !== "thread-tail") return;
var thread = document.getElementById("thread");
if (!thread) return event.preventDefault();
/* Quiet while this page is following a reply. That reply delivers its own
bubbles through the `done` frame, which is the only channel that can get
the *order* right -- and it is the one window in which the transcript's
order moves underneath us, since `_inject` restamps the placeholder to
sort after a prompt taken into it. Asking during it is how a page appends
a bubble it already has.
Exact rather than approximate: a page holding an incomplete assistant
bubble always carries this attribute, which is the state machine
`_message.html` documents. */
if (thread.querySelector("[sse-connect]")) return event.preventDefault();
/* Deliberately not `article.msg:last-of-type`. That is per-parent, and
`querySelector` returns the first match in document order -- so on a
compacted chat it answers with the last article inside
`<details class="compacted">` rather than the newest message. The last of
everything matching is what "the last bubble this page holds" means. */
var articles = thread.querySelectorAll("article.msg");
var last = articles.length ? articles[articles.length - 1] : null;
if (!last || last.id.indexOf("msg-") !== 0) return event.preventDefault();
event.detail.parameters.after = last.id.slice(4);
});
/* Whatever the reason -- the composer's POST committing between this request
going out and its answer coming back, a `done` frame landing first -- if any
bubble in the answer is already on the page then the page has moved on since
the question was asked, and swapping would duplicate it. A duplicate here is
not cosmetic: it would carry a second `sse-connect` for one message.
This is the race `hx-sync` cannot reach, since the two requests come from
different elements. The whole answer is dropped rather than filtered: the
next poll is five seconds away and recomputes its cursor from a DOM that has
settled, which is a correct page one tick late instead of a wrong one now. */
document.body.addEventListener("htmx:beforeSwap", function (event) {
var elt = (event.detail && event.detail.elt) || event.target;
if (!elt || elt.id !== "thread-tail" || !event.detail) return;
var seen = /\bid="(msg-[^"]+)"/g;
var body = event.detail.serverResponse || "";
var match;
while ((match = seen.exec(body)) !== null) {
if (document.getElementById(match[1])) {
event.detail.shouldSwap = false;
return;
}
}
});
/* Tokens arriving over SSE are appended outside the normal swap cycle.
Narrowed to frames that land in the thread. `metrics`, `status`, `ask` and
`canvas` all arrive on this event too, so the unconditional version fired
up to seven times per version bump -- most of them for content that changes
no height at all. */
document.body.addEventListener("htmx:sseMessage", function (event) {
var target = event.target;
if (target && target.closest && !target.closest("#thread-scroll")) return;
scrollThread(false);
});
})();