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>
78 lines
3.2 KiB
HTML
78 lines
3.2 KiB
HTML
{% 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>
|