Files
LLeMbas/src/lembas/web/templates/chat/_tool_activity.html
T
HomerandClaude Opus 5 a16510aba8 The interface speaks Slovak
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>
2026-09-26 14:46:33 +00:00

194 lines
8.5 KiB
HTML

{% from "_macros.html" import icon %}
{#
What the model did before answering.
Rendered both live (streamed as a whole block, like the reasoning and the
answer) and from the stored message afterwards, so the sources behind an
answer stay in the transcript rather than vanishing when the stream ends.
EVERYTHING in here is third-party text and is untrusted, exactly as much as
model output is -- a search provider's results, an administrator's HTTP tool
relaying whatever it was pointed at, an MCP server's reply. Jinja autoescaping
covers it. Two things are handled separately: the URL, because `is_linkable`
is the only thing standing between a result carrying a javascript: URL and an
anchor pointing at it, and `event.text`, which is rendered as preformatted
text and deliberately NOT through services/markdown.py -- markdown is the one
path allowed to emit HTML, and this is the last content that should be given
it.
`kind` says how to label the event. Rows written before it existed have none,
so web_search reads as a search and everything else falls to the generic
branch: an old notes_search event used to claim it had searched the web.
What a tool is *called* is not decided here. `tool_label` and `tool_icon` are
Jinja globals over services/tool_labels.py, which is the one table the status
line and the approval card read too. It deliberately ignores an `event.label`
it recognises the name of: rows already on disk carry the SSH profile's name,
so a resolver that preferred the stored value would leave every existing
transcript saying "homeserver" where it means "Bash".
#}
{#
`message_id` and `step_index` place this block in the reply, and an id is
emitted only when the caller gave one. That is not defensiveness: an id is
what carries an *opened* block across a swap, and a caller with no message to
key on would emit the same id in every bubble on the page, which is worse than
emitting none. `_step.html` always passes one.
`steps.js` records which of these are open before a swap and puts them back
after. It works because the id is stable -- the step index comes from a mark,
the marks are append-only, and the finished bubble emits exactly what the live
one did, so a block opened at round three is still open after the `done` frame
replaces the whole article.
#}
{% for event in tool_events %}
{% set kind = event.kind or ('search' if event.name == 'web_search' else 'tool') %}
<details class="tool-activity {{ 'tool-activity--error' if event.status == 'error' }}"
{% if message_id | default('') %}id="tool-{{ message_id }}-{{ step_index | default(0) }}-{{ loop.index0 }}"{% endif %}>
<summary class="tool-activity__summary">
{{ icon(tool_icon(event), "icon--sm tool-activity__icon") }}
<span class="tool-activity__label">
{% if kind == 'search' %}
{# Prose, not "Web search · mallorn". A search is the one thing here
common enough to be worth a sentence. #}
{% if event.status == "error" %}
Web search failed
{% elif event.query %}
Searched the web for “{{ event.query }}”
{% else %}
Searched the web
{% endif %}
{% else %}
{% set label = tool_label(event) %}
{% if event.status == "error" %}
{{ label }} failed
{% else %}
{{ label }}
{% endif %}
{% if event.query %}
<span class="tool-activity__count">· {{ event.query }}</span>
{% endif %}
{% endif %}
{% if event.results %}
<span class="tool-activity__count">
· {{ event.results | length }} result{{ '' if event.results | length == 1 else 's' }}
</span>
{% endif %}
{% if event.edited %}
{# Corrected on the approval card before it ran, so what is shown above is
the reader's command and not the model's. Said out loud rather than
left to be inferred: attributing somebody's own typing to a model is
the same misattribution as the other way round, and a transcript read
back a week later has nothing else to go on. #}
<span class="badge">{{ t("edited by you") }}</span>
{% endif %}
{% if event.why %}
{# What the model said this call was for. In the summary rather than the
body because the body is collapsed: in Auto mode nothing stops for
approval, so without this a reader watches a list of commands with no
account of what any of them is for until the reply ends.
Model text, escaped like everything else here, and styled as a quieter
second line so it reads as the model's account rather than ours. #}
<span class="tool-activity__why">{{ event.why }}</span>
{% endif %}
</span>
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
</summary>
<div class="tool-activity__body">
{% if event.detail and kind != 'search' %}
<p class="tool-result__host">{{ event.detail }}</p>
{% endif %}
{% if event.error %}
<p class="tool-activity__error">{{ event.error }}</p>
{% elif not event.results and not event.text and not event.image %}
<p class="tool-activity__error">{{ t("Nothing was found.") }}</p>
{% endif %}
{% if event.image and event.image.id %}
{#
What was drawn, in the block that drew it. The picture is also an
attachment on this bubble, so the reader sees it without opening
anything; this copy is what makes the block make sense on its own once
it is expanded, beside the seed and the attempts.
The address is built here from an id and is never taken from the event.
That is the same rule the result links a few lines down follow, and it
matters more here: an event is a dict a runner wrote, and a `src` taken
from one would be a model-supplied URL fetched by the reader's browser.
`/api/files/{id}/content` is owner-checked on the way out.
#}
<a class="tool-image" href="/api/files/{{ event.image.id }}/content"
target="_blank" rel="noopener">
<img src="/api/files/{{ event.image.id }}/content" alt="{{ t('The generated image') }}"
loading="lazy"
{% if event.image.width %}width="{{ event.image.width }}"{% endif %}
{% if event.image.height %}height="{{ event.image.height }}"{% endif %}>
</a>
{% endif %}
{% if event.text %}
<pre class="tool-result__text">{{ event.text }}</pre>
{% endif %}
{% if event.diff %}
{#
What a write or an update changed, in the shape everybody already reads a
change in.
Every line is text off somebody's machine and is escaped exactly like the
rest of this file. The classification reads the first character and never
interprets the rest — a line is coloured, never parsed.
Header lines are checked BEFORE the bare +/- ones, or `+++ b/x.py` renders
as an addition and `--- a/x.py` as a removal at the top of every diff. The
test is against the `a/` and `b/` prefixes rather than against three
dashes, because a removed line whose own text begins with `--` produces
exactly three dashes too.
No whitespace between the spans: they are `display: block` inside a <pre>,
so a newline between two of them is a blank line on screen.
#}
<pre class="diff"><code>
{%- for line in event.diff.split("\n") -%}
{%- if line.startswith('@@') or line.startswith('--- a/')
or line.startswith('+++ b/') or line.startswith('diff ') -%}
{%- set cls = 'meta' -%}
{%- elif line.startswith('+') -%}
{%- set cls = 'add' -%}
{%- elif line.startswith('-') -%}
{%- set cls = 'del' -%}
{%- else -%}
{%- set cls = 'ctx' -%}
{%- endif -%}
<span class="diff__line diff__line--{{ cls }}">{{ line }}</span>
{%- endfor -%}
</code></pre>
{% endif %}
{% for result in event.results %}
<div class="tool-result">
{% set scheme = (result.url or "").split(":")[0] | lower %}
{% if scheme in ("http", "https") %}
<a class="tool-result__title" href="{{ result.url }}"
target="_blank" rel="noopener noreferrer nofollow">{{ result.title }}</a>
{% else %}
{# Not a link. A result is third-party text and its URL is not trusted to
be safe to click. #}
<span class="tool-result__title">{{ result.title }}</span>
{% endif %}
<span class="tool-result__host">{{ result.host }}</span>
{% if result.snippet %}
<p class="tool-result__snippet">{{ result.snippet }}</p>
{% endif %}
</div>
{% endfor %}
</div>
</details>
{% endfor %}