{% extends "base.html" %} {% from "_macros.html" import icon, mark, model_avatar, scene %} {% block title %}{{ chat.title if chat else t("New chat") }} - {{ brand.name }}{% endblock %} {% block head %} {% if terminal_enabled %} {% endif %} {% endblock %} {% block body_attrs %} data-authenticated="true"{% endblock %} {% block body %}
{% include "partials/sidebar.html" %}
{% include "partials/_sidebar_toggle.html" %}

{{ chat.title if chat else t("New chat") }} {# Rename, where the name is. A themed dialog through `data-prompt` rather than an inline field: the heading is in a flex row beside the badges and the connection chip, and swapping it for a text box moves all of them. The response is the same out-of-band pair the `done` frame sends, so the sidebar row follows without a second request. #} {% if chat %} {% endif %} {# Beside the title because it describes the chat rather than acting on it. The way out of it is Keep, in the overflow menu. #} {% if chat and chat.temporary %} {{ t("Temporary · read-only") }} {# Reported on the way out, so a chat that lives "until you leave it" does. A reload reports it too, and the page that comes back clears it again (chat_service.mark_back). #} {% if temporary_leaves %} {% endif %} {% endif %}

{# Where this chat runs. Beside the title because it describes the chat and cannot be changed -- update_chat refuses the connection and the directory with a 409 -- so it is of a kind with the Temporary badge rather than with the controls on the right. The mode is the one thing here that moves, and it stays down by the message box. #} {% if chat and chat.kind == "agent" %} {{ icon("bolt", "icon--sm") }} {# "Local" in LLeMbas Desktop when the chat's device is this computer, with the host in the tooltip (`local_device`). #} {{ (agent_device_shown.name if agent_device_shown and agent_device_shown.local else agent_device_name) or t("device missing") }} {{ chat.project_dir }} {% if chat.device_token_id and device_reports_model %} {% include "chat/_device_model.html" %} {% endif %} {% endif %}
{# A link, not a script: the flag lives in the URL, so it survives a reload and can be bookmarked. The URL is built in `chat_index` so it keeps the chosen model, folder and kind. #} {% if not chat and can.get("chat.create") %} {{ icon("clock") }} {% endif %} {% if models %} {% if can.get("chat.model_select") or not chat %} {% include "chat/_model_picker.html" %} {% elif current_model %} {{ current_model.label }} {% endif %} {% endif %} {# Before a chat exists, both of these depend on a decision being made in the browser -- the kind toggle and the connection select -- which the server cannot see. So they render carrying `data-agent-only` and start `hidden`; `ui.js` shows them when `lembas:agent-target` names a connection and hides them again when it stops. On a chat that exists the server's answer is complete and neither attribute appears. #} {% if terminal_enabled %} {# To the left of the inspector, and never open beside it: see the toggle group in app.js. #} {% endif %} {% if chat and user.is_admin %} {% endif %} {# No overflow menu here: Keep this chat, Chat settings and Compact are all in the chat's settings panel, opened from the composer's settings, and Compact is `/compact` too. #}
{% if not models %} {# Every fresh install lands here, so it points at the fix. #}
{{ icon("server", "empty__mark") }}

{{ t("No models available") }}

{% if user.is_admin %} {{ t("Add an OpenAI-compatible connection and %(name)s will load its models.", name=brand.name) }} {% else %} {{ t("No model connections have been set up yet. Ask an administrator.") }} {% endif %}

{% if user.is_admin %} {{ icon("server", "icon--sm") }} {{ t("Set up a connection") }} {% endif %}
{% else %}
{% if not messages %}
{{ scene() }}

{{ t("What would you ask?") }}

{{ t(brand.text.chat_empty) }}

{# Only on a chat that does not exist yet. An empty chat someone opened on purpose already has a model and a prompt chosen. #} {% if not chat and suggestions %}
{% for suggestion in suggestions %} {% endfor %}
{% endif %}
{% endif %} {# The same include the rewind response returns, so the conversation is described in one place. Markdown was rendered server-side in pages.py, keyed by message id. #} {% include "chat/_thread.html" %}
{% if chat %} {# Where a reply that started outside a request arrives. A schedule firing, or a background helper ending with its note, wakes the chat server-side (`wake.wake_chat`) and there is no channel to say so: the only stream is per-message, opened by a bubble this page has not got. See `GET /api/chats/{id}/tail`, and `app.js` for the cursor -- which is read from the DOM on `htmx:configRequest`, not rendered here, because the DOM is the honest answer to what this page already holds. OUTSIDE `#thread`, not in it: a rewind and a compaction both swap `chat/_thread.html` into that container with `innerHTML`, and a poller living inside would be swapped away by the first one and never fire again. OUTSIDE the composer's form as well, and it names its own target regardless -- htmx inherits `hx-target`, the form carries `#thread`, and a polling chip that once lived inside it demonstrated what an unstated target does to a transcript. `hx-sync="this:drop"` because a poll fires whether or not the last one has come back, and two answers computed from the same cursor are two copies of one bubble. The composer's POST is the other writer, and `hx-sync` cannot order two elements: app.js keeps the poll quiet while the composer sends, aborts one already out when it starts, and removes any bubble the page already has from whatever lands in `#thread`. #} {% endif %} {# A task chat has no composer, and that is the section rather than a restriction on it: a schedule's chat is written into by the schedule and read by a person, so there is nothing for them to send. The controls that *do* apply — run it now, pause it, edit it — take the row's place. Suppressed by not including it, not by hiding it. `chat/_composer.html` is the only thing that posts a message, so its absence is the guarantee; a hidden one would still be a form anybody could post to. #} {% if chat and chat.kind == "task" %} {% include "schedules/_strip.html" %} {% else %} {% if chat %} {# The chat's background jobs -- helpers started here, a device's jobs -- with Stop and a way into a helper. Above the plan, as the LLeMbas CLI's status bar sits above its prompt. #} {% with jobs = (jobs | default([])), oob = false %} {% include "chat/_jobs_strip.html" %} {% endwith %} {% endif %} {% with plan = (chat.plan if chat else {}), oob = false %} {% include "chat/_plan_panel.html" %} {% endwith %} {% include "chat/_composer.html" %} {% endif %} {% endif %}
{# The panels, mirroring the sidebar opposite, in the order they sit on screen: the terminal, then the inspector, then the chat's settings. Only ever one of them is open -- see the toggle group. #} {% if chat and chat.kind != "task" %} {% include "chat/_job_panel.html" %} {% endif %} {% if terminal_enabled %} {% include "chat/_terminal.html" %} {% endif %} {% if chat and user.is_admin %} {% include "chat/_inspector.html" %} {% endif %} {% if models and (chat or scope_families or scope_skills or scope_allow) and not (chat and chat.kind == "task") %} {% include "chat/_settings_panel.html" %} {% endif %} {# The window switch: on a phone, a side panel is the whole screen, and this is how you move between it and the conversation. A panel on a phone used to be a sheet over the conversation with a close button, which made the conversation a place you return to by closing things -- and the terminal and a prototype each a separate detour. Switching is the honest model at that width: there is room for one window, so say which, and let the others be one tap away. Shown only below the phone breakpoint and only while a panel is open (the `:has()` rule in app.css); above it the panels sit beside the conversation and the topbar buttons are the switch. `data-window` rather than `data-toggle`, because pressing the window you are in must not close it. #}
{% endblock %} {% block scripts %} {# Unconditional: every chat has a transcript, and this is what keeps a block somebody opened open across the swaps that arrive twelve times a second. #} {% if terminal_enabled %} {# Only where it can be used. xterm is nearly three times everything else vendored, so a plain chat must never load it. #} {% endif %} {#- The `python` tool's runner: only on a chat that may use it, with Python switched on by an administrator and Pyodide fetched and verified on this server (api/pyodide.py, services/pyodide_files.py). -#} {% if chat and python_runner is defined and python_runner(chat) %} {% endif %} {% endblock %}