{% from "_macros.html" import icon, mark %}
{#
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) %}
{{ "LLeMbas" if message.role == "assistant" else (user.name or "You") }}
{% if message.model_id %}
{{ 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