5f020ef33f
Seven things. **Reasoning starts closed.** The answer is what the reader is waiting for; the thinking is one click away. **Image borders.** .attachments__image was a block-level <a>, so its border stretched the full column around a narrow picture. inline-block, and the frame is the picture. Same fix for the composer thumbnail. **Markdown now renders during the stream.** The generator re-renders the answer so far and sends it as a `render` event at most every 100ms, swapped with innerHTML, instead of appending escaped tokens and formatting everything at the end. Re-rendering whole rather than appending is the point: a list or a code fence is only correct once its context exists, and partial syntax resolves itself as more arrives. Measured against a live model: 29 render events, formatting visible from the first content token. **Stop button.** A stop request goes into an in-process set the generator checks between chunks; whatever arrived is kept, because a half-written answer the reader chose to cut short is still worth having. Measured: stream ended 0.2s after the request, 1155 characters preserved, message marked stopped rather than errored. Navigating away does the same thing via CancelledError. **Rewind and edit.** Edit one of your own turns and everything after it is deleted, then the conversation runs on from there. Deliberately not branching: that needs a UI for choosing between versions, and "go back and try again from here" is what was asked for. The form states how many messages will be discarded before you confirm. **Custom model picker.** A <select> renders only text in an <option>, so it can never show an avatar. Built from buttons and a hidden input, with descriptions, capability tags, a filter box past eight models, and arrow-key navigation written out by hand since there is no native widget doing it. **Notification system.** lembas.notify/confirm/prompt in ui.js, built on <dialog> so focus trapping, Escape and page inertness come from the browser. htmx:confirm is intercepted, so every existing hx-confirm gets the themed dialog with no change at the call site; the browser's grey confirm() is gone from every template. 230 tests, ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
381 lines
13 KiB
JavaScript
381 lines
13 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));
|
||
|
||
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"); });
|
||
|
||
var timeout = options.timeout == null ? 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);
|
||
|
||
/* 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;
|
||
}
|
||
|
||
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();
|
||
});
|
||
})();
|