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:
@@ -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