df2d5cc877
This is the blank agent chat, and it was not the transcript rewrite at all.
`hx-target` is inherited. The composer's form carries `hx-target="#thread"`
with `hx-swap="beforeend"`, which is what makes a sent message append a bubble.
The background-jobs chip I added last commit sits inside that form and declared
`hx-swap="outerHTML"` and nothing else -- which reads as "replace yourself" and
resolved, through the form, to "replace #thread with yourself". On load, and
then again every five seconds.
So an agent chat rendered its reply and then went blank, the reader's own prompt
along with it, because the entire transcript had been swapped out for a chip
that renders empty when no jobs are running. Only agent chats, because that is
the only place the chip exists. The server logged nothing, because nothing there
had gone wrong: every page render, every SSE frame and every stored row was
correct throughout, which is why four rounds of looking at the server found
nothing.
Both the chip and the element that loads it now carry `hx-target="this"`, and
`tests/test_chat.py` walks the composer's form and refuses anything that fetches
without saying where its answer goes. Checked against the bug before being kept.
Worth being precise about what made it invisible: the markup was correct. There
is nothing wrong with `hx-swap="outerHTML"` on an element with no target -- it
means "swap yourself" right up until an ancestor disagrees. It is the same
family as the trigger bound where the event does not go, and the same lesson:
assert the resolved property, not the attributes.
My earlier fix in 74a3c0f was a real defect -- an sse-swap container must not
hold another -- but it was not this, and I should have said "best hypothesis"
rather than "found it" when I shipped it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
498 lines
24 KiB
HTML
498 lines
24 KiB
HTML
{% from "_macros.html" import icon %}
|
|
{#
|
|
The composer, used both inside an existing chat and on /chat where no chat
|
|
row exists yet.
|
|
|
|
The only difference is where it posts. With a chat, the turn is appended to
|
|
the thread in place; without one, /api/chats/start creates the chat and
|
|
redirects, and the reply streams on arrival because the page renders the
|
|
unfinished assistant message with its sse-connect. That is what stops an
|
|
opened-and-abandoned chat ever being written to the database.
|
|
|
|
The attachment chips live INSIDE this form on purpose. Each carries a hidden
|
|
file_ids input, and being inside the form is what gets them serialised with
|
|
the message. Keeping them outside and reaching for hx-include does not work:
|
|
that attribute only has an effect on the element issuing the request.
|
|
|
|
Layout: chips, then the text, then one toolbar row underneath carrying
|
|
everything that acts on the message. The kind selector and the connection
|
|
used to sit in a strip *above* the text, inside the same bordered card, where
|
|
they read as debris floating in the input rather than as controls. Below the
|
|
text they are in the same place as attach and send, which is where the hand
|
|
already is.
|
|
#}
|
|
{# The chat, the connection and the directory, where composer.js can read them
|
|
without parsing them back out of a URL. On a new chat the connection is
|
|
still being chosen, so the select and the hidden field win over these. #}
|
|
<div class="composer" {% if can.get("files.upload") %}data-dropzone{% endif %}
|
|
{% if chat %}data-chat-id="{{ chat.id }}"{% endif %}
|
|
{% if chat and chat.ssh_profile_id %}data-profile-id="{{ chat.ssh_profile_id }}"
|
|
data-project-dir="{{ chat.project_dir }}"{% endif %}>
|
|
{% if can.get("files.upload") %}
|
|
{# Outside the form: it is only ever read by JavaScript, and inside it would
|
|
be submitted as an empty file part on every message.
|
|
|
|
Two inputs rather than one whose accept attribute is rewritten: changing
|
|
accept and then calling click() in the same tick is unreliable in Safari,
|
|
and two hidden inputs cost nothing. #}
|
|
<input class="visually-hidden" type="file" id="file-input" multiple
|
|
data-upload-url="/api/files{% if chat %}?chat_id={{ chat.id }}{% endif %}"
|
|
accept=".pdf,.txt,.md,.csv,.json,.py,.js,.ts,.rs,.go,.sh,.sql,.yaml,.yml,.toml,.log"
|
|
onchange="window.lembas.uploadFiles(this.files); this.value = ''">
|
|
<input class="visually-hidden" type="file" id="image-input" multiple accept="image/*"
|
|
onchange="window.lembas.uploadFiles(this.files); this.value = ''">
|
|
{% endif %}
|
|
|
|
<div class="composer__inner">
|
|
<form class="composer__form"
|
|
{% if chat %}
|
|
hx-post="/api/chats/{{ chat.id }}/messages"
|
|
hx-target="#thread" hx-swap="beforeend"
|
|
hx-on::after-request="if (event.detail.successful) {
|
|
this.reset();
|
|
document.getElementById('attachments').replaceChildren();
|
|
window.lembas.autosize(this.querySelector('textarea'));
|
|
window.lembas.scrollThread(true);
|
|
}"
|
|
{% else %}
|
|
hx-post="/api/chats/start" hx-swap="none"
|
|
{% endif %}>
|
|
|
|
<div class="composer__attachments" id="attachments"></div>
|
|
|
|
{% if not chat and current_model %}
|
|
<input type="hidden" name="model_id" value="{{ current_model.model_id }}">
|
|
{% endif %}
|
|
{% if not chat and starting_temporary %}
|
|
<input type="hidden" name="temporary" value="true">
|
|
{% endif %}
|
|
{% if not chat and starting_folder %}
|
|
{# `/api/chats/start` has accepted a folder_id since folders existed and
|
|
nothing ever sent one, so the only way into a folder was to make the
|
|
chat elsewhere and move it. This is "New chat here" arriving. #}
|
|
<input type="hidden" name="folder_id" value="{{ starting_folder.id }}">
|
|
{% endif %}
|
|
|
|
{#
|
|
The text, with a mirror behind it.
|
|
|
|
A textarea cannot style its own contents, so the mirror holds the same
|
|
text with every character transparent and contributes nothing but a
|
|
rounded rectangle behind each recognised token. The real text stays in
|
|
the textarea, where it is native and selectable -- the other way round,
|
|
showing the mirror's text and hiding the textarea's, means any style
|
|
drift renders as doubled or blurred glyphs instead of a rectangle a
|
|
pixel out of place.
|
|
|
|
composer.js fills it. Without JavaScript there is simply no mirror.
|
|
#}
|
|
<div class="composer__field">
|
|
<div class="composer__mirror" data-composer-mirror aria-hidden="true"></div>
|
|
<textarea class="composer__input" name="content" rows="1"
|
|
data-autosize data-max-height="320" data-composer-input
|
|
placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}"
|
|
aria-label="Message" {{ 'autofocus' if not chat }}></textarea>
|
|
</div>
|
|
|
|
<div class="composer__toolbar">
|
|
<div class="composer__tools">
|
|
{% if can.get("files.upload") %}
|
|
{# A menu rather than the file picker straight away: there are four ways
|
|
to attach something now, and only one of them is a file on disk.
|
|
Uses the same picker machinery as the model chooser -- see ui.js. #}
|
|
<div class="picker picker--up" data-picker>
|
|
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
|
aria-haspopup="menu" aria-expanded="false"
|
|
aria-label="Attach" title="Attach">
|
|
{{ icon("attach") }}
|
|
</button>
|
|
|
|
<div class="picker__menu picker__menu--compact" data-picker-menu role="menu"
|
|
hidden aria-label="Attach">
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
data-attach="file">
|
|
{{ icon("attach", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">File</span>
|
|
<span class="picker__option-note">PDF, text, code</span>
|
|
</span>
|
|
</button>
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
data-attach="image">
|
|
{{ icon("image", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">Image</span>
|
|
<span class="picker__option-note">Sent only to vision models</span>
|
|
</span>
|
|
</button>
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
data-attach="link">
|
|
{{ icon("link", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">Link</span>
|
|
<span class="picker__option-note">Fetch a page and attach its text</span>
|
|
</span>
|
|
</button>
|
|
{% if can.get("library.use") %}
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
data-attach="knowledge">
|
|
{{ icon("archive", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">Knowledge</span>
|
|
<span class="picker__option-note">From your library</span>
|
|
</span>
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{% endif %}
|
|
|
|
{#
|
|
What this chat may use.
|
|
|
|
This slot used to be an `@` button that inserted the character and
|
|
got out of the way -- which the `@` key already does, from the
|
|
keyboard, without a button. It then kept that as a row inside the
|
|
menu, which was the same redundancy one level down: a menu you open
|
|
to press a button that types one character. Both are gone. Typing
|
|
`@` is untouched; composer.js recognises the token on its own and
|
|
knows nothing about this menu.
|
|
|
|
With the row gone there is nothing to show on a chat with no scope,
|
|
so the guard is `has_scope` alone rather than `has_scope or can
|
|
upload`. An empty menu is worse than no button.
|
|
|
|
The rows are `<label>`s wrapping a checkbox and deliberately carry
|
|
no `role="menuitem"`: ui.js closes a picker when a menuitem is
|
|
clicked, which is right for an action menu and wrong for a list of
|
|
switches you want to set several of. That is the whole reason this
|
|
needs no JavaScript at all.
|
|
|
|
The verb is on the CHECKBOX, not on the label and not on a form: the
|
|
element carrying `name` has to be the element carrying the request,
|
|
which is what tests/conftest.py:control_named exists to pin.
|
|
|
|
Only on an existing chat -- there is no row to write to before one
|
|
exists, and a switch that went nowhere is worse than no switch.
|
|
#}
|
|
{% set has_scope = chat and (scope_families or scope_skills or scope_allow) %}
|
|
{% if has_scope %}
|
|
<div class="picker picker--up" data-picker>
|
|
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
|
aria-haspopup="menu" aria-expanded="false"
|
|
aria-label="Toggle" title="Toggle">
|
|
{{ icon("sliders") }}
|
|
</button>
|
|
|
|
<div class="picker__menu picker__menu--scope" data-picker-menu role="menu"
|
|
hidden aria-label="Toggle">
|
|
{% if has_scope %}
|
|
<p class="picker__lede">
|
|
Switched off here only. Everything is on unless you say otherwise.
|
|
</p>
|
|
{% endif %}
|
|
|
|
{% if scope_families %}
|
|
<p class="picker__group">Tools</p>
|
|
{% for family in scope_families %}
|
|
<label class="picker__option picker__option--toggle">
|
|
<input type="checkbox" name="on" value="true"
|
|
{{ 'checked' if family.on }}
|
|
hx-post="/api/chats/{{ chat.id }}/scope" hx-swap="none"
|
|
hx-vals='{"kind": "family", "name": "{{ family.gate }}"}'>
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">{{ family.label }}</span>
|
|
</span>
|
|
</label>
|
|
{% endfor %}
|
|
{% endif %}
|
|
|
|
{% if scope_skills %}
|
|
<p class="picker__group">Skills</p>
|
|
{% for skill in scope_skills %}
|
|
<label class="picker__option picker__option--toggle">
|
|
<input type="checkbox" name="on" value="true"
|
|
{{ 'checked' if skill.on }}
|
|
hx-post="/api/chats/{{ chat.id }}/scope" hx-swap="none"
|
|
hx-vals='{"kind": "skill", "name": "{{ skill.name }}"}'>
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">{{ skill.name }}</span>
|
|
{% if skill.description %}
|
|
<span class="picker__option-note">{{ skill.description }}</span>
|
|
{% endif %}
|
|
</span>
|
|
</label>
|
|
{% endfor %}
|
|
{% endif %}
|
|
|
|
{# What this chat has been told to stop asking about, one entry per
|
|
press of "Always allow this" on an approval card. Shown because
|
|
a standing permission nobody can see is one nobody can revoke --
|
|
and because the entry for a command is the exact command line,
|
|
which is worth being able to read back.
|
|
|
|
Clear swaps the whole block for nothing, so the row disappears.
|
|
htmx does not swap on a 204, which is why the route returns an
|
|
empty body. #}
|
|
{% if scope_allow %}
|
|
<div class="picker__allow">
|
|
<p class="picker__group">Always allowed here</p>
|
|
{% for entry in scope_allow %}
|
|
<p class="picker__allow-entry">{{ entry }}</p>
|
|
{% endfor %}
|
|
<button class="picker__option" type="button" role="menuitem"
|
|
hx-post="/api/chats/{{ chat.id }}/allow/clear"
|
|
hx-target="closest .picker__allow" hx-swap="outerHTML">
|
|
{{ icon("trash", "icon--sm") }}
|
|
<span class="picker__option-body">
|
|
<span class="picker__option-name">Ask me about these again</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
{% endif %}
|
|
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{#
|
|
What this conversation is and where it runs.
|
|
|
|
On a new chat all of it is editable. On an existing one the kind, the
|
|
connection and the directory are fixed -- update_chat refuses them
|
|
with a 409, because a transcript whose earlier turns ran somewhere
|
|
else is not one conversation -- so they render as a read-only chip and
|
|
only the mode stays live. The mode is the exception on purpose: it
|
|
decides what gets asked about, not what the conversation is.
|
|
#}
|
|
{% if not chat and agent_profiles %}
|
|
<div class="composer__context" data-agent-picker>
|
|
{# Seeded from `?kind=`, which is how the sidebar's Agent side opens
|
|
this screen already on the right fork. `ui.js` reads the checked
|
|
radio when it wires the picker, so the hidden field and the
|
|
revealed connection follow from this and nothing else. #}
|
|
<input type="hidden" name="kind" value="{{ starting_kind | default('chat') }}"
|
|
id="chat-kind">
|
|
|
|
<div class="segmented" role="group" aria-label="Kind of chat">
|
|
<label class="segmented__option">
|
|
<input type="radio" name="kind_choice" value="chat"
|
|
{{ '' if starting_kind == 'agent' else 'checked' }}>
|
|
<span>{{ icon("chat", "icon--sm") }} Chat</span>
|
|
</label>
|
|
<label class="segmented__option">
|
|
<input type="radio" name="kind_choice" value="agent"
|
|
{{ 'checked' if starting_kind == 'agent' }}>
|
|
<span>{{ icon("bolt", "icon--sm") }} Agent</span>
|
|
</label>
|
|
</div>
|
|
|
|
<span class="composer__agent" data-agent-extra hidden>
|
|
<select class="select select--sm composer__connection"
|
|
name="ssh_profile_id" aria-label="Connection">
|
|
{% for profile in agent_profiles %}
|
|
<option value="{{ profile.id }}" data-dir="{{ profile.default_dir }}"
|
|
{{ 'disabled' if not profile.verified }}>
|
|
{{ profile.name }}{{ ' — not checked' if not profile.verified }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
|
|
{# A button and a hidden field, not a text box. The text box was
|
|
real and submitted, but unlabelled and squeezed to a few
|
|
characters by the select beside it, so it read as broken. A path
|
|
is also something you would rather find than spell. #}
|
|
<input type="hidden" name="project_dir" value="" data-dir-value>
|
|
<button class="btn btn--sm composer__dir" type="button" data-dir-browse
|
|
aria-label="Project directory"
|
|
title="Choose the directory this chat works in">
|
|
{{ icon("folder", "icon--sm") }}
|
|
<span class="composer__dir-path" data-dir-label>/</span>
|
|
</button>
|
|
|
|
{% if agent_modes %}
|
|
<select class="select select--sm" name="agent_mode" aria-label="Approval mode">
|
|
{% for value, label, hint in agent_modes %}
|
|
<option value="{{ value }}" title="{{ hint }}">{{ label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% endif %}
|
|
</span>
|
|
</div>
|
|
|
|
{% elif chat and chat.kind == "agent" %}
|
|
{# Only the mode. The connection and the directory moved to the topbar:
|
|
they cannot change -- update_chat refuses both with a 409 -- so they
|
|
are facts about the chat rather than controls on the message, and
|
|
they were taking a slot in a row that has work to do.
|
|
|
|
Its own form: nesting one inside the composer's form is invalid HTML
|
|
and the browser drops the inner one.
|
|
|
|
The verb is on the select, not on that form. htmx binds a trigger to
|
|
the annotated element itself, and `change` fires here and bubbles
|
|
through this element's *ancestors* -- which a sibling form is not.
|
|
`form=` scopes the values, and only the values. #}
|
|
<div class="composer__context">
|
|
<select class="select select--sm" name="agent_mode" aria-label="Approval mode"
|
|
form="agent-mode-form"
|
|
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none">
|
|
{% for value, label, hint in agent_modes %}
|
|
<option value="{{ value }}" title="{{ hint }}"
|
|
{{ 'selected' if value == chat.agent_mode }}>{{ label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
|
|
{# What is still running on the far side. Only where jobs can exist at
|
|
all -- a chat without background commands enabled has none, and a chip
|
|
that could never show anything is a chip that only takes room.
|
|
|
|
`hx-trigger="load"` and not the contents inline: the listing reads the
|
|
`agent_jobs` table, and the composer is rendered on every page load
|
|
and after every reply. One request five seconds later costs nothing;
|
|
a query on the render path costs it every time. #}
|
|
{# `hx-target="this"` is load-bearing and is the whole of a bug that
|
|
blanked every agent chat. htmx INHERITS `hx-target` from ancestors,
|
|
and this sits inside the composer's form, which carries
|
|
`hx-target="#thread"` so that a sent message appends a bubble. Without
|
|
a target of its own, this element's request resolved to `#thread` --
|
|
and with `outerHTML` it replaced the entire transcript with the chip.
|
|
The reply rendered and then vanished, prompt and all.
|
|
|
|
Anything in here that fetches must say where the answer goes. There
|
|
is a test. #}
|
|
{% if jobs_enabled %}
|
|
<div hx-get="/api/chats/{{ chat.id }}/jobs" hx-trigger="load"
|
|
hx-target="this" hx-swap="outerHTML"></div>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
{#
|
|
How hard a reasoning model should think. Outside the agent branch
|
|
above, because it applies to any chat.
|
|
|
|
Only on a model an administrator has marked as **reasoning**: that
|
|
flag has existed since the beginning with no reader at all, and
|
|
offering the control everywhere would be offering a setting that does
|
|
nothing almost everywhere.
|
|
|
|
On an existing chat it writes on change, and needs the same treatment
|
|
the mode select gets: the verb on the control, `form=` for the values.
|
|
Before there is a chat there is nothing to PATCH, so it is an ordinary
|
|
field of the composer's own form and `_new_chat` reads it -- which is
|
|
what makes the setting choosable before the first prompt rather than
|
|
after it.
|
|
#}
|
|
{% if current_model and current_model.capabilities_json.get("reasoning") %}
|
|
<select class="select select--sm" name="reasoning_effort" data-effort
|
|
aria-label="Reasoning effort" title="How hard this model should think"
|
|
{% if chat %}
|
|
form="chat-params-form"
|
|
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
|
{% endif %}>
|
|
{#
|
|
It shows the level actually in force, never the word "default".
|
|
|
|
On an existing chat that is `resolved_effort`, which is the chat's
|
|
own value and nothing else -- `build_request` reads the same field,
|
|
so what is shown is what is sent, by construction. Before there is a
|
|
chat it is the model's configured level, which `_new_chat` seeds
|
|
onto the row, so the same holds.
|
|
|
|
"off" is a sentinel and NOT an empty value. `start_chat` declares
|
|
`reasoning_effort: str = Form("")`, so absent and empty are
|
|
indistinguishable there -- with `value=""` the reader would pick off
|
|
and silently get the model's default.
|
|
#}
|
|
{% set chosen = resolved_effort if chat
|
|
else (current_model.params_json or {}).get('reasoning_effort') %}
|
|
<option value="off" {{ 'selected' if chosen not in efforts }}>Effort: off</option>
|
|
{% for value in efforts %}
|
|
<option value="{{ value }}" {{ 'selected' if chosen == value }}>
|
|
Effort: {{ value }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% endif %}
|
|
|
|
<div class="composer__actions">
|
|
{% if can_dictate %}
|
|
{# Recording is started and stopped by the same button; audio.js swaps
|
|
data-mic-state and the icon with it. #}
|
|
<button class="btn btn--icon composer__btn composer__mic" type="button"
|
|
data-mic data-mic-state="idle"
|
|
aria-label="Dictate a message" title="Dictate a message">
|
|
<span class="composer__icon composer__icon--mic">{{ icon("mic") }}</span>
|
|
<span class="composer__icon composer__icon--recording" aria-hidden="true">
|
|
{{ icon("stop-circle") }}
|
|
</span>
|
|
</button>
|
|
{% endif %}
|
|
|
|
{#
|
|
One button, two jobs. While a reply is being written it becomes Stop,
|
|
because that is where the hand already is and a second button sitting
|
|
permanently beside Send is clutter that is wrong most of the time.
|
|
|
|
ui.js flips data-composer-action, and the type with it: as `submit`
|
|
the form's own handler sends, as `button` the click handler stops.
|
|
Both icons are rendered here and chosen in CSS, so the swap costs no
|
|
layout and cannot flash an empty button.
|
|
#}
|
|
<button class="btn btn--primary btn--icon composer__btn" type="submit"
|
|
data-composer-action="send" aria-label="Send">
|
|
<span class="composer__icon composer__icon--send">{{ icon("send") }}</span>
|
|
<span class="composer__icon composer__icon--stop" aria-hidden="true">
|
|
<span class="composer__stop-square"></span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
{# Outside the composer's form, and referenced by the two selects' `form`
|
|
attributes above. These carry no htmx of their own: they exist so that
|
|
`Nt(e)` -- htmx's "which form do the values come from", which reads
|
|
`e.form` before falling back to `closest("form")` -- resolves to a form
|
|
holding exactly one control. Without them the PATCH would carry the
|
|
composer's `content`, `model_id` and `project_dir`, and `update_chat`
|
|
answers `project_dir` with a 409.
|
|
|
|
hx-patch and not hx-post: there is no POST for a chat, only PATCH, and
|
|
htmx shows nothing when a request 405s -- which is how these controls
|
|
spent the first half of their lives doing nothing. #}
|
|
{% if chat and chat.kind == "agent" %}
|
|
<form id="agent-mode-form"></form>
|
|
{% endif %}
|
|
{% if chat %}
|
|
<form id="chat-params-form"></form>
|
|
{% endif %}
|
|
|
|
<p class="composer__hint">
|
|
Enter to send, Shift+Enter for a new line.
|
|
<button class="composer__hint-link" type="button"
|
|
onclick="window.lembasCommands && window.lembasCommands.help()">
|
|
/ for commands
|
|
</button>,
|
|
@ for files.
|
|
{% if can.get("files.upload") %}
|
|
Drag files in, or paste an image.
|
|
{% if current_model and not current_model.capabilities_json.get("vision") %}
|
|
<strong>{{ current_model.label }} has no vision</strong>, so images
|
|
will not be sent — documents still will.
|
|
{% endif %}
|
|
{% endif %}
|
|
</p>
|
|
</div>
|
|
|
|
{% if can.get("files.upload") %}
|
|
<div class="dropzone-overlay" aria-hidden="true">
|
|
{{ icon("attach", "icon--lg") }}
|
|
<span>Drop to attach</span>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|