{% from "_macros.html" import icon, mark, model_avatar %}
{% set speaking_model = (models_by_id | default({})).get(message.model_id) %}
{#
One message bubble, in either of two states.
An incomplete assistant message renders the streaming shell: it carries the
sse-connect that opens the reply stream. This is deliberately the ONLY thing
that starts a generation, which means a page load showing an unfinished reply
picks it up again -- reloading after a dropped connection retries rather than
leaving a permanently half-written answer.
A complete message renders its finished body: Markdown for the assistant,
escaped plain text for everyone else.
#}
{% set streaming = (message.role == "assistant" and not message.complete) %}
{% if message.role == "assistant" %}
{# The model that actually wrote this turn, which is not necessarily the
one the chat is set to now. Falls back to the LLeMbas mark when the
model has been removed or has no image of its own. #}
{% if speaking_model %}
{{ model_avatar(speaking_model, cls="msg__avatar") }}
{% else %}
{{ mark(cls="msg__mark", uid="m" ~ message.id) }}
{% endif %}
{% else %}
{{ (user.name or "?")[0]|upper }}
{% endif %}
{% if message.role == "assistant" %}
{{ speaking_model.label if speaking_model else "LLeMbas" }}
{% else %}
{{ user.name or "You" }}
{% endif %}
{% if message.model_id and (not speaking_model or speaking_model.display_name) %}
{# Only worth showing when it adds something the author line does not. #}
{{ message.model_id }}
{% endif %}
{% if message.attachments %}
{# Above the text, matching the order they were added and the order the
model receives them. #}
{% for attachment in message.attachments %}
{% if attachment.is_image %}
{% else %}
{{ icon("attach", "icon--sm") }}
{{ attachment.filename }}
{{ attachment.human_size }}
{%- if attachment.pages %} · {{ attachment.pages }} page{{ '' if attachment.pages == 1 else 's' }}{% endif %}
{%- if attachment.truncated %} · truncated{% endif %}
{%- if attachment.extracted_text %}
· view extracted text
{%- endif %}
{% if attachment.extraction_error %}
{{ attachment.extraction_error }}
{% endif %}
{% endif %}
{% endfor %}
{% 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. #}
{{ icon("sparkle", "icon--sm reasoning__icon") }}
Thinking
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
{# 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. #}
{% elif message.reasoning and not message.error %}
{# Collapsed once finished: the answer is what the reader came for, and
the thinking is there if they want to audit it. #}
{{ icon("sparkle", "icon--sm reasoning__icon") }}
{% if message.reasoning_ms %}
Thought for {{ message.reasoning_ms | duration }}
{% else %}
Reasoning
{% endif %}
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
{% endif %}
{# An attachment-only turn has no text; rendering the bubble anyway would
leave an empty box under the file. #}
{% if not streaming %}
{# The raw source, so the copy button yields Markdown rather than rendered
text. A hidden div and not a