Live Markdown, stop, rewind, custom picker, dialogs

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>
This commit is contained in:
Jaroslav Beneš
2026-07-21 14:33:04 +02:00
parent f744232d25
commit a071d8486b
20 changed files with 1151 additions and 54 deletions
+197
View File
@@ -578,3 +578,200 @@ button, input, textarea, select {
}
.sidebar[data-collapsed="true"] { display: none; }
}
/* --- Toasts ----------------------------------------------------------------
Bottom-right, stacked, above dialogs' backdrop but out of the way of the
composer.
*/
.toasts {
position: fixed;
right: var(--sp-4);
bottom: var(--sp-4);
z-index: 60;
display: flex;
flex-direction: column;
gap: var(--sp-2);
max-width: min(24rem, calc(100vw - var(--sp-8)));
pointer-events: none;
}
.toast {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
background: var(--surface-raised);
box-shadow: var(--shadow-lg);
font-size: var(--text-sm);
pointer-events: auto;
opacity: 0;
transform: translateY(0.5rem);
transition: opacity var(--transition), transform var(--transition);
}
.toast.is-in { opacity: 1; transform: none; }
.toast--success { border-left-color: var(--success); }
.toast--error { border-left-color: var(--danger); }
.toast--warning { border-left-color: var(--warning); }
.toast__text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.toast__close {
flex: none;
border: 0;
background: none;
color: var(--ink-faint);
cursor: pointer;
font-size: var(--text-lg);
line-height: 1;
padding: 0 0.15rem;
}
.toast__close:hover { color: var(--ink); }
/* --- Dialogs ----------------------------------------------------------------
<dialog> gives focus trapping, Escape and page inertness for free.
*/
.dialog {
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--surface);
color: var(--ink);
padding: 0;
max-width: min(28rem, calc(100vw - var(--sp-8)));
width: 100%;
box-shadow: var(--shadow-lg);
}
.dialog::backdrop { background: var(--scrim); backdrop-filter: blur(2px); }
.dialog__form { padding: var(--sp-5); display: flex; flex-direction: column; gap: var(--sp-3); }
.dialog__title { font-size: var(--text-lg); }
.dialog__message {
margin: 0;
color: var(--ink-muted);
font-size: var(--text-sm);
line-height: var(--leading-relaxed);
overflow-wrap: anywhere;
}
.dialog__actions {
display: flex;
justify-content: flex-end;
gap: var(--sp-2);
margin-top: var(--sp-2);
}
/* A destructive confirmation needs the weight of a filled button, not the
outline treatment .btn--danger gives a row action. */
.btn--danger-solid {
background: var(--danger);
border-color: var(--danger);
color: var(--ink-inverse);
}
.btn--danger-solid:hover:not(:disabled) {
background: var(--danger-hover);
border-color: var(--danger-hover);
color: var(--ink-inverse);
}
/* --- Model picker ----------------------------------------------------------
Built by hand because a <select> renders only text in an <option> -- no
avatar, no description, no badges.
*/
.picker { position: relative; }
.picker__button {
display: flex;
align-items: center;
gap: var(--sp-2);
height: var(--control-h);
max-width: 16rem;
padding: 0 var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink);
font-size: var(--text-sm);
cursor: pointer;
transition: border-color var(--transition-fast);
}
.picker__button:hover { border-color: var(--border-strong); }
.picker__button[aria-expanded="true"] {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.picker__avatar { width: 1.4rem; height: 1.4rem; border-radius: var(--radius-sm); flex: none; }
.picker__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
font-weight: 500;
}
.picker__chevron { flex: none; color: var(--ink-faint); }
.picker__menu {
position: absolute;
top: calc(100% + var(--sp-1));
right: 0;
z-index: 30;
width: min(24rem, calc(100vw - var(--sp-8)));
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface-raised);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.picker__search { padding: var(--sp-2); border-bottom: 1px solid var(--border); }
.input--sm { height: var(--control-h-sm); font-size: var(--text-xs); }
.picker__list { max-height: 22rem; overflow-y: auto; scrollbar-width: thin; padding: var(--sp-1); }
.picker__option {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
width: 100%;
padding: var(--sp-2);
border: 0;
border-radius: var(--radius);
background: none;
color: var(--ink);
text-align: left;
cursor: pointer;
}
.picker__option:hover, .picker__option:focus-visible { background: var(--surface-hover); }
.picker__option.is-selected { background: var(--accent-soft); }
.picker__option .picker__avatar { width: 1.75rem; height: 1.75rem; margin-top: 0.1rem; }
.picker__option-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.15rem; }
.picker__option-name {
display: flex;
align-items: center;
gap: var(--sp-1);
font-size: var(--text-sm);
font-weight: 500;
}
.picker__pin { color: var(--gold); }
.picker__option-desc {
font-size: var(--text-xs);
color: var(--ink-faint);
line-height: var(--leading-normal);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.picker__option-tags { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.picker__option-tags:empty { display: none; }
.tag {
font-size: 0.6875rem;
padding: 0 0.35rem;
border-radius: var(--radius-sm);
background: var(--gold-soft);
color: var(--gold);
}
.picker__tick { color: var(--accent); flex: none; margin-top: 0.35rem; }
.picker__empty {
padding: var(--sp-4);
margin: 0;
text-align: center;
font-size: var(--text-sm);
color: var(--ink-faint);
}
+38 -7
View File
@@ -89,8 +89,7 @@
}
/* User turns and mid-stream assistant text are plain text, so newlines and
runs of spaces have to survive. */
.msg__body--plain,
.msg__body--streaming { white-space: pre-wrap; }
.msg__body--plain { white-space: pre-wrap; }
.msg--user .msg__body--plain {
background: var(--bubble-user);
@@ -106,7 +105,6 @@
/* Shown until the first token arrives, then hidden by the sibling selector
below -- no JavaScript involved in either direction. */
.msg__waiting { padding: var(--sp-2) 0; }
.msg__body--streaming:not(:empty) + .msg__waiting { display: none; }
.dots { display: inline-flex; gap: 0.25rem; align-items: center; }
.dots i {
@@ -124,8 +122,9 @@
30% { opacity: 1; transform: translateY(-2px); }
}
/* A caret trailing the text while it streams. */
.msg__body--streaming::after {
/* A caret trailing the text while it streams. Attached to the last block so it
sits at the end of the prose rather than on a line of its own. */
.msg__body--live:not(:empty) > :last-child::after {
content: "";
display: inline-block;
width: 0.45rem;
@@ -198,6 +197,34 @@
50% { opacity: 1; }
}
/* --- Stop, notes and editing ---------------------------------------------- */
.msg__waiting {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-2) 0;
}
/* Once the answer has content the caret carries the "still going" signal, so
the dots go, but Stop must stay reachable until the stream ends. */
.msg__body--live:not(:empty) + .msg__waiting .dots { display: none; }
.msg__stop { color: var(--ink-muted); }
.msg__stop:hover { color: var(--danger); border-color: var(--danger); }
.msg__note {
display: flex;
align-items: center;
gap: var(--sp-1);
margin: var(--sp-2) 0 0;
font-size: var(--text-xs);
color: var(--ink-faint);
font-style: italic;
}
.msg--editing .msg__main { width: 100%; }
.edit-form { display: flex; flex-direction: column; gap: var(--sp-3); }
.edit-form .textarea { min-height: 4rem; }
/* --- Message actions ------------------------------------------------------ */
.msg__actions {
display: flex;
@@ -416,6 +443,7 @@
.chip--error .chip__icon { color: var(--danger); }
.chip__thumb {
display: block;
width: 2.25rem;
height: 2.25rem;
border-radius: var(--radius-sm);
@@ -455,19 +483,22 @@
gap: var(--sp-2);
margin-bottom: var(--sp-2);
}
/* inline-block, not block: as a block the anchor filled the column and drew its
border at full width around a narrow image. Now the frame is the picture. */
.attachments__image {
display: block;
display: inline-block;
max-width: 100%;
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
line-height: 0;
}
.attachments__image img {
display: block;
max-width: min(22rem, 100%);
max-height: 20rem;
width: auto;
height: auto;
object-fit: contain;
}
.attachments__doc {
display: flex;
+380
View File
@@ -0,0 +1,380 @@
/*
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();
});
})();