969 strings, an instance default and a per-person choice, and no half-done corner: the admin prose is translated too. Design and reasoning: LLeMbas.wiki/Translations. KEYED BY THE ENGLISH SENTENCE A missing entry renders the key, which is the English -- so an untranslated string looks as it always did, an English instance is byte-for-byte 1.6.0, and a half-finished catalogue is a half-translated page rather than a page of dotted key names. The cost is that editing an English sentence orphans its translation silently, which is what tests/test_translations.py asserts in both directions. No gettext: .po -> .mo is a build step and this project does not have one. A JINJA GLOBAL, AND THEREFORE A CONTEXTVAR `t()` is a global for the reason `brand` already documents -- render() is bypassed by 25 TemplateResponse calls and 8 get_template().render() calls, the latter being the SSE frames, which have no Request at all. A global is bound once at import and the language is per person, so the active language is a ContextVar set per request. 🚨 `get_current_user` had to become `async def`. FastAPI runs a sync dependency in a threadpool, and anyio copies the context in and discards it on the way out -- so the language was set where nothing could see it and every page rendered in the instance's language whatever anybody had chosen, with no error anywhere. NOT TRANSLATED, ON PURPOSE Everything a model reads: the 60 prompt fragments, and the dates in harness.py, schedule/runner.py and schedule/compile.py. Only `i18n.stamp` is localised, and only where a person reads it -- with the *format* translatable as well as the words, because "26. septembra 2026" is a different pattern rather than the same one with different words in it. A process locale is not an option: global, not thread-safe, two people's pages at once. A second fragment telling models to answer in the reader's language was written during this work and removed: `core.style` has said it since long before, and test_an_empty_override_turns_a_fragment_off caught the duplicate. THE BULK PASS 1213 sites wrapped by a one-off script that only touched patterns it could not misread. It got three wrong in a way that mattered -- `t('…')` inside `attr="…"` where the sentence held an apostrophe, closing the Jinja string and 500ing two pages whose partials no test renders. tests/test_translations.py now compiles all 110 templates. It also wrapped the product's own name, an SSH key header, a keystroke hint and an example URL, all taken back out: a string is not translatable just because it is a string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
113 lines
4.4 KiB
HTML
113 lines
4.4 KiB
HTML
{% from "_macros.html" import icon %}
|
|
{#
|
|
The Chat/Agent switch, the folder tree and the unfiled chats.
|
|
|
|
Its own partial because it is rendered from two places: the sidebar on every
|
|
page, and `POST /api/preferences/sidebar-kind` when the switch is flicked.
|
|
Pinned models stay above it in `sidebar.html` -- they start a chat of either
|
|
kind and are not part of what is being switched.
|
|
|
|
The switch is INSIDE the swapped fragment on purpose. Targeting only the tree
|
|
would leave the two buttons showing the side you just left, which is the same
|
|
class of failure as a control whose verb goes somewhere the event does not:
|
|
the request works and the interface says otherwise. Each button keeps a stable
|
|
id so htmx puts focus back on the one that was pressed.
|
|
|
|
`sidebar_kind` is narrowed already: `sidebar_context` drops a folder holding
|
|
nothing of this kind at any depth, so the heading below cannot appear above
|
|
nothing. It is still passed down to `_folder.html`, which needs it for its own
|
|
contents and for the children it recurses into.
|
|
#}
|
|
<div id="sidebar-tree">
|
|
{% if sidebar_split %}
|
|
{#
|
|
The same component the new-chat screen uses to pick a kind, which is the
|
|
same choice in a different place. The verb goes on the input, not on the
|
|
wrapper: `change` fires on the control and bubbles through its DOM
|
|
ancestors, and htmx binds its listener to the annotated element itself.
|
|
|
|
`hx-vals` rather than relying on the input's own value being collected --
|
|
the two radios are not inside a form, so what htmx would gather is worth
|
|
not depending on.
|
|
#}
|
|
<div class="segmented segmented--grow" role="group" aria-label="{{ t('Which chats to show') }}">
|
|
{% for option, label, glyph in [("chat", "Chats", "chat"), ("agent", "Agents", "terminal")] %}
|
|
<label class="segmented__option">
|
|
<input type="radio" name="sidebar_kind" value="{{ option }}"
|
|
id="sidebar-kind-{{ option }}"
|
|
{{ 'checked' if sidebar_kind == option }}
|
|
hx-post="/api/preferences/sidebar-kind"
|
|
hx-vals='{"kind": "{{ option }}"}'
|
|
hx-trigger="change"
|
|
hx-target="#sidebar-tree" hx-swap="outerHTML">
|
|
<span>{{ icon(glyph, "icon--sm") }} {{ label }}</span>
|
|
</label>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{#
|
|
"New chat" becomes "New agent chat" with the switch, and it lives above the
|
|
scroll area rather than inside this fragment -- so it comes along out of
|
|
band. Only when this is the fragment response: on a full page load the row
|
|
is already rendered in its own place, and a second copy marked
|
|
`hx-swap-oob` would be a stray element sitting in the tree.
|
|
#}
|
|
{% if oob %}
|
|
{% with oob = true %}
|
|
{% include "partials/_sidebar_actions.html" %}
|
|
{% include "partials/_sidebar_pinned.html" %}
|
|
{% endwith %}
|
|
{% endif %}
|
|
|
|
{% if folders %}
|
|
<div class="nav-group">
|
|
<div class="nav-group__label">Folders</div>
|
|
{% for folder in folders %}
|
|
{% include "partials/_folder.html" %}
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="nav-group">
|
|
<div class="nav-group__label">
|
|
{{ "Agent chats" if sidebar_kind == "agent" else "Chats" }}
|
|
</div>
|
|
{% if unfiled_chats %}
|
|
{% for chat_item in unfiled_chats %}
|
|
{% include "partials/_chat_link.html" %}
|
|
{% endfor %}
|
|
{% else %}
|
|
<p class="nav-empty">
|
|
{%- if sidebar_kind == "agent" -%}
|
|
No agent chats yet. Nothing is stirring out there.
|
|
{%- else -%}
|
|
No chats yet. The road begins here.
|
|
{%- endif -%}
|
|
</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{#
|
|
Archived chats, closed, and absent entirely when there are none.
|
|
|
|
A `<details>` rather than a page of their own: archiving is for getting a
|
|
conversation out of the way, not for filing it somewhere, and a second
|
|
screen to visit would make putting one back a journey. Closed by default
|
|
because that is the whole point, and the browser keeps the open state
|
|
across the out-of-band swaps the unread poll makes -- the same property the
|
|
folder tree relies on.
|
|
#}
|
|
{% if archived_chats %}
|
|
<details class="nav-group nav-group--archived">
|
|
<summary class="nav-group__label">
|
|
{{ icon("archive", "icon--sm") }} Archived
|
|
<span class="nav-group__count">{{ archived_chats|length }}</span>
|
|
</summary>
|
|
{% for chat_item in archived_chats %}
|
|
{% include "partials/_chat_link.html" %}
|
|
{% endfor %}
|
|
</details>
|
|
{% endif %}
|
|
</div>
|