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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
@@ -90,7 +90,7 @@
|
||||
</span>
|
||||
<button class="btn btn--sm btn--danger" type="submit"
|
||||
formaction="/admin/connections/{{ connection.id }}/delete" formnovalidate
|
||||
onclick="return confirm('Delete the connection “{{ connection.name }}”? Existing chats keep their history.')">
|
||||
data-confirm-button="Delete the connection “{{ connection.name }}”? Existing chats keep their history.">
|
||||
{{ icon("trash", "icon--sm") }} Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -158,7 +158,8 @@
|
||||
<div class="card__footer">
|
||||
<span class="text-xs faint">Deleting a group leaves its members alone.</span>
|
||||
<form method="post" action="/admin/groups/{{ group.id }}/delete"
|
||||
onsubmit="return confirm('Delete the group “{{ group.name }}”?')">
|
||||
data-confirm="Delete the group “{{ group.name }}”? Its members keep their accounts."
|
||||
data-confirm-title="Delete group">
|
||||
<button class="btn btn--sm btn--danger" type="submit">
|
||||
{{ icon("trash", "icon--sm") }} Delete
|
||||
</button>
|
||||
|
||||
@@ -150,7 +150,8 @@
|
||||
|
||||
{% if account.id != user.id %}
|
||||
<form method="post" action="/admin/users/{{ account.id }}/delete"
|
||||
onsubmit="return confirm('Delete {{ account.email }} and all their chats? This cannot be undone.')">
|
||||
data-confirm="Delete {{ account.email }} and all their chats? This cannot be undone."
|
||||
data-confirm-title="Delete account">
|
||||
<button class="btn btn--sm btn--danger" type="submit">
|
||||
{{ icon("trash", "icon--sm") }} Delete
|
||||
</button>
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<script src="{{ url_for('static', path='vendor/htmx-ext-sse.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', path='vendor/alpine.min.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', path='js/app.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', path='js/ui.js') }}" defer></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
A user turn switched into edit mode, replacing its bubble in place.
|
||||
|
||||
Saving rewinds: the message is rewritten and everything after it is deleted,
|
||||
then the conversation runs again from that point. That is destructive, so the
|
||||
button says so and asks first.
|
||||
#}
|
||||
<article class="msg msg--user msg--editing" id="msg-{{ message.id }}">
|
||||
<div class="msg__gutter" aria-hidden="true">
|
||||
<span class="msg__initial">{{ (user.name or "?")[0]|upper }}</span>
|
||||
</div>
|
||||
|
||||
<div class="msg__main">
|
||||
<header class="msg__meta">
|
||||
<span class="msg__author">{{ user.name or "You" }}</span>
|
||||
<span class="msg__model">editing</span>
|
||||
</header>
|
||||
|
||||
<form class="edit-form"
|
||||
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/edit"
|
||||
hx-target="#thread" hx-swap="innerHTML"
|
||||
hx-confirm="Rewind here? The {{ following }} message{{ '' if following == 1 else 's' }} after this one will be deleted."
|
||||
data-confirm-title="Rewind the conversation"
|
||||
data-confirm-label="Rewind and send">
|
||||
<textarea class="textarea" name="content" rows="3" data-autosize
|
||||
data-max-height="320" aria-label="Edit message"
|
||||
autofocus>{{ message.content }}</textarea>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">
|
||||
{{ icon("refresh", "icon--sm") }}
|
||||
{% if following %}Rewind and send{% else %}Send again{% endif %}
|
||||
</button>
|
||||
<button class="btn" type="button"
|
||||
hx-get="/api/chats/{{ chat.id }}/messages/{{ message.id }}/cancel-edit"
|
||||
hx-target="#msg-{{ message.id }}" hx-swap="outerHTML">
|
||||
Cancel
|
||||
</button>
|
||||
{% if following %}
|
||||
<span class="text-xs faint">
|
||||
{{ following }} later message{{ '' if following == 1 else 's' }} will be discarded.
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
@@ -87,26 +87,32 @@
|
||||
{% endif %}
|
||||
|
||||
{% if streaming %}
|
||||
{# Reasoning arrives before the answer, so this block sits above it. It
|
||||
starts open (watching a model think is the point) and the :has() rule
|
||||
in chat.css hides the whole thing while it is still empty, so models
|
||||
that emit no reasoning never show an empty box. #}
|
||||
<details class="reasoning reasoning--live" id="reasoning-{{ message.id }}" open>
|
||||
{# Reasoning arrives before the answer, so this block sits above it.
|
||||
Closed by default -- the answer is what the reader is waiting for, and
|
||||
the thinking is one click away. The :has() rule in chat.css hides the
|
||||
whole thing while it is still empty, so models that emit no reasoning
|
||||
never show an empty box. #}
|
||||
<details class="reasoning reasoning--live" id="reasoning-{{ message.id }}">
|
||||
<summary class="reasoning__summary">
|
||||
{{ icon("sparkle", "icon--sm reasoning__icon") }}
|
||||
<span class="reasoning__label">Thinking</span>
|
||||
<span class="reasoning__label">Thinking…</span>
|
||||
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
|
||||
</summary>
|
||||
<div class="reasoning__body" sse-swap="reasoning" hx-swap="beforeend"></div>
|
||||
</details>
|
||||
|
||||
{# Tokens are appended here as they arrive. The cursor is a CSS
|
||||
pseudo-element on the empty parent, so it disappears by itself once
|
||||
the first token lands. #}
|
||||
<div class="msg__body msg__body--streaming" id="stream-{{ message.id }}"
|
||||
sse-swap="token" hx-swap="beforeend"></div>
|
||||
{# The server re-renders the answer as Markdown a few times a second and
|
||||
replaces this whole block, so formatting appears as the model writes
|
||||
rather than snapping into place at the end. #}
|
||||
<div class="msg__body msg__body--live" id="stream-{{ message.id }}"
|
||||
sse-swap="render" hx-swap="innerHTML"></div>
|
||||
<div class="msg__waiting">
|
||||
<span class="dots"><i></i><i></i><i></i></span>
|
||||
<button class="btn btn--sm msg__stop" type="button"
|
||||
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/stop"
|
||||
hx-swap="none">
|
||||
{{ icon("x", "icon--sm") }} Stop
|
||||
</button>
|
||||
</div>
|
||||
{% elif message.reasoning and not message.error %}
|
||||
{# Collapsed once finished: the answer is what the reader came for, and
|
||||
@@ -140,6 +146,9 @@
|
||||
{% endif %}
|
||||
{% elif message.role == "assistant" %}
|
||||
<div class="msg__body">{{ body_html|safe }}</div>
|
||||
{% if message.stopped %}
|
||||
<p class="msg__note">{{ icon("x", "icon--sm") }} Stopped. This reply is cut short.</p>
|
||||
{% endif %}
|
||||
{% elif message.content %}
|
||||
<div class="msg__body msg__body--plain">{{ message.content }}</div>
|
||||
{% endif %}
|
||||
@@ -152,6 +161,14 @@
|
||||
data-copy="msg-body-{{ message.id }}" aria-label="Copy message">
|
||||
{{ icon("copy", "icon--sm") }}
|
||||
</button>
|
||||
{% if message.role == "user" %}
|
||||
<button class="btn btn--icon btn--sm" type="button"
|
||||
hx-get="/api/chats/{{ chat.id }}/messages/{{ message.id }}/edit"
|
||||
hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"
|
||||
aria-label="Edit and retry from here">
|
||||
{{ icon("pencil", "icon--sm") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if message.role == "assistant" %}
|
||||
<button class="btn btn--icon btn--sm" type="button"
|
||||
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/regenerate"
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
{% from "_macros.html" import icon, model_avatar %}
|
||||
{#
|
||||
Model picker.
|
||||
|
||||
A real dropdown rather than a <select>, because a <select> cannot show an
|
||||
image, a description or capability badges -- browsers render only text in an
|
||||
<option>. The hidden input is what actually carries the value, so the control
|
||||
still behaves like a form field.
|
||||
|
||||
Inside a chat it PATCHes the chat; on /chat it navigates, because there is no
|
||||
chat row to patch yet.
|
||||
#}
|
||||
<div class="picker" data-picker>
|
||||
<button class="picker__button" type="button" data-picker-toggle
|
||||
aria-haspopup="listbox" aria-expanded="false">
|
||||
{% if current_model %}
|
||||
{{ model_avatar(current_model, cls="picker__avatar") }}
|
||||
<span class="picker__label">{{ current_model.label }}</span>
|
||||
{% else %}
|
||||
<span class="picker__label">Choose a model</span>
|
||||
{% endif %}
|
||||
{{ icon("chevron-down", "icon--sm picker__chevron") }}
|
||||
</button>
|
||||
|
||||
<div class="picker__menu" data-picker-menu role="listbox" hidden
|
||||
aria-label="Models">
|
||||
{% if models|length > 8 %}
|
||||
<div class="picker__search">
|
||||
<input class="input input--sm" type="search" data-picker-filter
|
||||
placeholder="Filter models…" aria-label="Filter models">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="picker__list">
|
||||
{% for model in models %}
|
||||
<button class="picker__option {{ 'is-selected' if current_model and model.model_id == current_model.model_id }}"
|
||||
type="button" role="option"
|
||||
aria-selected="{{ 'true' if current_model and model.model_id == current_model.model_id else 'false' }}"
|
||||
data-picker-value="{{ model.model_id }}"
|
||||
data-picker-search="{{ model.label|lower }} {{ model.model_id|lower }}">
|
||||
{{ model_avatar(model, cls="picker__avatar") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">
|
||||
{{ model.label }}
|
||||
{% if model.pinned %}{{ icon("pin", "icon--sm picker__pin") }}{% endif %}
|
||||
</span>
|
||||
{% if model.description %}
|
||||
<span class="picker__option-desc">{{ model.description }}</span>
|
||||
{% endif %}
|
||||
<span class="picker__option-tags">
|
||||
{% for name, on in (model.capabilities_json or {}).items() %}
|
||||
{% if on %}<span class="tag">{{ name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
</span>
|
||||
</span>
|
||||
{% if current_model and model.model_id == current_model.model_id %}
|
||||
{{ icon("check", "icon--sm picker__tick") }}
|
||||
{% endif %}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="picker__empty" data-picker-empty hidden>No model matches that.</p>
|
||||
</div>
|
||||
|
||||
{% if chat %}
|
||||
{# Changing the value fires the PATCH; htmx serialises the hidden input. #}
|
||||
<form hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||
hx-trigger="change from:find input" data-picker-form>
|
||||
<input type="hidden" name="model_id" data-picker-input
|
||||
value="{{ current_model.model_id if current_model else '' }}">
|
||||
</form>
|
||||
{% else %}
|
||||
{# No chat yet: selecting navigates so the whole composer re-renders with the
|
||||
right vision warning and the right hidden model_id. #}
|
||||
<span hidden data-picker-navigate="/chat?model="></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
{#
|
||||
The whole thread. Returned after a rewind, which changes an arbitrary number
|
||||
of messages at once -- replacing the lot is simpler and less error-prone than
|
||||
working out which individual bubbles to remove.
|
||||
#}
|
||||
{% for message in messages %}
|
||||
{% with body_html = bodies.get(message.id, "") %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
@@ -27,26 +27,7 @@
|
||||
<div class="topbar__actions">
|
||||
{% if models %}
|
||||
{% if can.get("chat.model_select") or not chat %}
|
||||
{# Models are listed in the administrator's order. Pinning is a
|
||||
sidebar shortcut and deliberately does not reorder this. #}
|
||||
<label class="model-select">
|
||||
{% if current_model %}{{ model_avatar(current_model, cls="model-select__avatar") }}{% endif %}
|
||||
<select class="select select--bare" name="model_id" id="model-select"
|
||||
aria-label="Model"
|
||||
{% if chat %}hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||
hx-trigger="change"{% else %}onchange="
|
||||
const u = new URL(window.location);
|
||||
u.searchParams.set('model', this.value);
|
||||
window.location = u;
|
||||
"{% endif %}>
|
||||
{% for model in models %}
|
||||
<option value="{{ model.model_id }}"
|
||||
{{ 'selected' if current_model and model.model_id == current_model.model_id }}>
|
||||
{{ model.label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% include "chat/_model_picker.html" %}
|
||||
{% elif current_model %}
|
||||
<span class="badge">{{ current_model.label }}</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<button class="btn btn--icon btn--sm" type="button"
|
||||
hx-delete="/api/chats/{{ chat_item.id }}"
|
||||
hx-confirm="Delete “{{ chat_item.title }}”? This cannot be undone."
|
||||
data-confirm-title="Delete chat"
|
||||
hx-swap="none"
|
||||
aria-label="Delete chat">
|
||||
{{ icon("trash", "icon--sm") }}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<button class="btn btn--icon btn--sm" type="button"
|
||||
hx-delete="/api/folders/{{ folder.id }}"
|
||||
hx-confirm="Delete the folder “{{ folder.name }}”? Chats inside it are kept."
|
||||
data-confirm-title="Delete folder"
|
||||
hx-swap="none"
|
||||
aria-label="Delete folder">
|
||||
{{ icon("trash", "icon--sm") }}
|
||||
|
||||
Reference in New Issue
Block a user