Five fixes from the May 2 2026 maintainer review:
1. messages and tool_calls now use copy.deepcopy() — prior plain assignment
shared list refs between source and duplicate, so appending a turn to one
mutated the other.
2. copied_session.save() called explicitly — pre-fix, the duplicate was
in-memory only until the user sent a turn. Refreshing mid-flow lost it.
3. pinned and archived reset to False — duplicating an archived conversation
should produce a visible (un-archived) copy.
4. Missing-session error is now status=404 (was default 400).
5. Removed redundant `import uuid` / `import time` inside the handler — both
are already at the top of routes.py.
Test updates:
- Two existing static-grep tests widened to accept the new
`copy.deepcopy(session.messages)` form alongside the original
`messages=session.messages`.
- Five new static-grep regression tests pin each of the five fixes so
reverting any single one trips a test.
All 3775 tests pass.
Co-authored-by: Alexey Dsov <AlexeyDsov@users.noreply.github.com>
The JavaScript _normalizeConfiguredModelKey function had the same bug as the
Python _norm_model_id function that was fixed in commit d6164cd. It used
substring(indexOf(':')+1) which only removes the first colon-separated segment,
leaving provider names in the normalized model ID.
For example, '@custom:jingdong:GLM-5' became 'jingdong:glm.5' instead of 'glm.5'.
This caused duplicate Primary badges to appear in the model dropdown when using
custom providers with @provider:model ID format.
Changes:
- Replace substring(indexOf(':')+1) with split(':').pop() to strip all colon prefixes
- Add provider name to badge label for clarity (e.g., 'Primary (jingdong)')
When the webui auth session expires (e.g., after a server restart),
api() returns undefined after redirecting to /login. Previously,
loadSession() and _ensureMessagesLoaded() would dereference the
undefined response and throw, surfacing a confusing 'Failed to load
session' toast while the browser was already navigating away.
Add guards after api() calls that may trigger 401 redirects:
- loadSession(): bail early if data is undefined
- _ensureMessagesLoaded(): return silently if data is missing
- _loadOlderMessages(): return silently if data is missing
This prevents the stuck loading state and unnecessary error toasts
when the user is already being redirected to re-authenticate.
Fixes#1391 (reported as 'Failed to load session' after restart)
The _norm_model_id function was using split(':', 1)[1] which only removed
the first colon-separated segment, leaving provider names in the normalized
model ID. For example, '@custom:jingdong:GLM-5' became 'jingdong:glm.5'
instead of 'glm.5'.
This caused the default model injection check to fail, resulting in a
duplicate 'Default' group being added to the model list even when the
model already existed with a provider prefix.
Changes:
- Use split(':')[-1] to get the last segment after all colons
- Use split('/')[-1] consistently for slash-separated paths
- Replace local _norm lambda with _norm_model_id function call
Fixes duplicate Default group appearing in model dropdown when using
custom providers with @provider:model ID format.
Closes#1442 (server-side _LOGIN_LOCALE missing ja/pt/ko)
Closes#1443 (promote _isImeEnter helper to 6 other Safari Enter guards)
Closes#1446 (glued-bold-heading lift for LLM thinking-block output)
Closes#1447 (markdown heading visual hierarchy in chat messages)
All four issues were filed by the Opus pre-release advisor on the v0.50.264 batch
or by Cygnus via Discord (relayed by @AvidFuturist, May 1 2026). They share a
common shape — narrow, well-scoped, independent of each other, all adding
regression tests.
== #1442: _LOGIN_LOCALE parity (api/routes.py + static/i18n.js) ==
Added entries for ja/pt/ko to the server-side _LOGIN_LOCALE dict that renders
the localized login page BEFORE the JS i18n bundle loads. With v0.50.264
shipping Japanese as the 8th built-in locale, ja/pt/ko users were seeing the
English login page even with their language preference set.
While auditing static/i18n.js for English leakage, also fixed:
- ko: 10 user-facing login/sign-out/password keys still in English
- es: 3 sign-out/auth-disabled keys still in English
Tests: tests/test_login_locale_parity.py (20 tests) — pins both invariants:
(a) every locale in i18n.js LOCALES has a matching _LOGIN_LOCALE entry
(b) every locale's login-flow keys (13 of them) are translated, not English
== #1443: window._isImeEnter promotion ==
PR #1441 fixed the Safari IME-composition Enter race in the chat composer
(`#msg`) by widening the guard from `e.isComposing` to a `_isImeEnter(e)`
helper that combines three signals (isComposing || keyCode===229 ||
_imeComposing flag). Six other Enter-input handlers were left on the original
narrow guard and would still drop IME composition Enters on Safari for
Japanese/Chinese/Korean users.
Promoted the helper to `window._isImeEnter` (defined in static/boot.js) and
replaced the `e.isComposing` guards at all six sites:
- static/sessions.js: session rename, project create, project rename
- static/ui.js: app dialog (confirm/prompt), message edit, workspace rename
The state-free part of the helper (`isComposing || keyCode===229`) handles
Safari's race for any focused input without needing per-input composition
listeners — only `#msg` keeps the local `_imeComposing` flag.
Tests:
- tests/test_issue1443_ime_helper_promotion.py (9 tests) — pins each site
+ verifies no raw `e.isComposing` Enter-guards remain in sessions.js/ui.js
- tests/test_ime_composition.py — alternation regex extended to accept
the windowed helper form (loosen-test-on-shape-change pattern from
v0.50.264 reflection notes)
== #1446: glued-bold-heading lift (static/ui.js renderMd + Python mirror) ==
LLMs in thinking/reasoning mode emit "section headers" glued to the end of the
previous paragraph with no whitespace:
Para 1 text.**Heading to Para 2**
Para 2 text.**Heading to Para 3**
The renderer correctly produces inline `<strong>` per CommonMark, but it looks
like trailing emphasis on the body text rather than a section break. Cygnus
reported this as "Markdown feedback 2 of 3."
Added a single regex pre-pass in renderMd():
s.replace(/([.!?])\*\*([^*\n]{1,80})\*\*\n\n/g, '$1\n\n**$2**\n\n')
Constraints chosen to avoid false positives:
- Trigger only on `[.!?]` IMMEDIATELY before `**` (no space) — almost always
an LLM-glued heading, not intentional emphasis
- Inner text ≤80 chars, no `*` or newline (single-line only)
- Trailing `\n\n` required — preserves "this is **important** to know."
mid-paragraph emphasis untouched
- Position: after rawPreStash restore, before fence_stash restore — fenced
code blocks stay protected (their content is `\x00P` / `\x00F` tokens
when the lift runs)
Mirrored in tests/test_sprint16.py render_md() so both stay in sync.
Tests: tests/test_issue1446_glued_heading_lift.py (17 tests, 5 of which drive
the actual ui.js renderMd via node) — covers all 3 trigger forms (.!?), all 4
preserve-emphasis cases the issue spec'd, fenced/inline code protection,
chained glued headings, source-level position pin, regex shape pin.
== #1447: markdown heading visual hierarchy (static/style.css) ==
Pre-fix sizes in `.msg-body`:
h1 18px, h2 16px, h3 14px (= body), h4 13px, h5 12px, h6 11px
So h3 was indistinguishable from body and h4/h5/h6 were SMALLER than body.
Cygnus's report: "Markdown feedback 3 of 3 — Headings seem to be missing
across the board in Hermes. They're there, but all plaintext."
New sizes:
h1 24px (border-bottom) h2 20px (border-bottom) h3 17px h4 15px
h5 14px (uppercase, tracked) h6 13px (uppercase, tracked, muted)
All headings now `font-weight:700` + `color:var(--strong)` for stronger ink.
h5/h6 use uppercase + letter-spacing for "label-style" affordance instead
of being smaller-than-body.
Synced .preview-md (file preview pane) to match exactly so a markdown file
preview and a chat message render identically. Added missing h4/h5/h6 rules
to .preview-md (it only had h1-h3 before).
Updated data-font-size="small"/"large" h1-h6 overrides to scale
proportionally with the new defaults. Hierarchy preserved at all three
font-size settings.
Tests: tests/test_issue1447_heading_hierarchy.py (9 tests) — pins the size
hierarchy, the bottom borders on h1/h2, the uppercase affordance on h5/h6,
the .preview-md sync, and the small/large override scaling.
== Verification ==
pytest tests/ -q → 3748 passed (+56 new)
bash ~/WebUI/scripts/run-browser-tests.sh → 20 + 11 PASS
bash ~/WebUI/scripts/webui_qa_agent.sh 8789 → 23/23 PASS
Visual confirmation in browser at port 8789:
- Heading hierarchy clearly visible at all 6 levels
- Glued-bold lift produces separate paragraphs as designed
- window._isImeEnter accessible from any module after boot.js
- Login page renders ja/pt/ko strings correctly (curl -s /login)
REQUIRED:
- _fully_unquote_path range(3) -> range(10) — defense-in-depth so quadruple-
encoded .. is rejected by validator instead of slipping through (not
exploitable but contract violation)
- docs/EXTENSIONS.md trust-model callout moved to top of file with explicit
'don't enable in untrusted env / don't point at user-writable dir' guidance
NICE-TO-HAVE (taken since Nathan asked for all fixes big and small):
- URL list cap at _MAX_URL_LIST=32 to avoid pathological rendering
- One-shot WARNING log for rejected URLs (silent drop now visible to admin)
- One-shot WARNING log for URL list truncation
- MIME map: ttf (font/ttf), otf (font/otf), wasm (application/wasm)
5 regression tests in tests/test_pr1445_opus_followups.py pin all invariants.
Opus advisor caught a recoverable footgun in PR #1441's manual flag: if
focus is lost mid-composition (window blur or older Safari WebKit IME
quirk), compositionend may never fire and _imeComposing stays true
until the next full composition cycle. Result: Enter-to-send is
silently broken until page reload — an unrecoverable stuck state for
something that's supposed to be transient.
Add a blur listener that also resets the flag. Cheap belt-and-suspenders
against the stuck state. Adds 1 regression test pinning the listener.
(other Opus findings logged in /tmp/stage-264-brief.md as follow-up
issues: _LOGIN_LOCALE parity for ja/pt/ko, promote _isImeEnter to the
6 other Safari-affected Enter guards in sessions.js + ui.js)
The markdown fence regex /```([\s\S]*?)```/g had no line anchoring. A literal
triple backtick inside code block content (e.g. a regex with ``` in a lookbehind,
or a script that documents fences) terminated the outer fence at the wrong place.
The leaked tail then went through bold/italic/inline-code passes, eating `*`
characters as italic markers and emitting literal </strong> tags into the
rendered output.
CommonMark §4.5 requires that an opening code fence be the first non-whitespace
content of a line (up to 3 spaces of indent allowed) and that the closing fence
also start a line. This patch updates 3 sites + the Python mirror to use that
invariant:
static/ui.js:1559 renderMd() fenced-block stash (assistant messages)
static/ui.js:66 _renderUserFencedBlocks() (user messages)
static/ui.js:2599 _stripForTTS() (TTS speech pre-strip)
tests/test_sprint16.py Python mirror
Pattern: (^|\n)[ ]{0,3}```(?:([\s\S]*?)\n)?[ ]{0,3}```(?=\n|$)
The non-capturing (?:...\n)? group keeps empty fences (```\n```) working;
without it, a body+\n is required and the closing fence on the very next line
no longer matches. The lead group (^|\n) is prefixed back to the stash token
so paragraphs above don't bleed into the <pre> block.
20 regression tests in tests/test_issue1438_fence_anchoring.py cover:
- Cygnus's exact repro from Discord (May 1 2026)
- Inline ``` mid-paragraph (must not open fence)
- Partial/streaming fence with no close (must not eat content)
- Empty fences with and without language tag
- 3-space indented fences (allowed) vs 4-space (not a fence)
- Multiple adjacent blocks
- Bold/italic/inline-code surviving after a fence
- Source-level guards on all 3 patched sites + lead-prefix invariant
Empirical browser verification (live JS, on bug repro):
Before fix: </code></pre>[^\n]<em>|%%[ \t]</em>... ← truncated, italic leak
After fix: <pre><code>...```[^\n]*|%%...</code></pre> ← intact, regex preserved
Tests: 3678 passed (+20 from new test file, was 3658), 0 failures.
Reported-By: Cygnus (Discord)
Relayed-By: @AvidFuturist
Closes#1438
Adds a ja locale entry (828 keys) under static/i18n.js LOCALES,
inserted between en and ru. All existing keys translated to natural
concise Japanese suitable for UI labels, with placeholders ({0}, etc.)
and template literals preserved verbatim.
- _lang: 'ja', _label: '日本語', _speech: 'ja-JP'
- 828 keys (matches en, including the documented duplicate keys
whose JS last-wins semantics are preserved)
- syntax verified with `node -c static/i18n.js`
Tested live on a self-hosted instance; Settings → Language → 日本語
selects the new locale and switches the UI text.
East Asian IMEs (Japanese/Chinese/Korean) use Enter to commit composition.
The existing isComposing guard misses Safari, where the committing keydown
fires after compositionend with isComposing=false. Also track composition
manually and check keyCode===229 for broader coverage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix two-layer bug where `/api/session` returned `context_length=0` for
sessions that pre-date #1318, then the frontend silently fell back to
cumulative `input_tokens` and the 128K JS default, producing nonsense
indicators like "100" capped from "890% used (context exceeded), 1.2M
/ 131.1k tokens used".
Empirical impact: 23 of 75 sessions on dev server rendered >100% before
this fix. #1356 fixed the same symptom on the live SSE path but missed
the GET /api/session load path that older sessions go through.
Two-layer fix:
1. Backend (api/routes.py:1295-1313) — resolve context_length via
agent.model_metadata.get_model_context_length() when the persisted
value is 0. Mirrors api/streaming.py:2333-2342.
2. Frontend (static/ui.js:1269) — drop the cumulative `input_tokens`
fallback. When last_prompt_tokens is missing, render "·" + "tokens
used" (existing !hasPromptTok branch) instead of computing a
percentage from the cumulative total.
10 regression tests in tests/test_issue1436_context_indicator_load_path.py
covering both layers + the empty-model edge case (avoids the 256K
default-for-unknown-model trap that get_model_context_length('') returns).
Verified live: claude-opus-4-7 session with input_tokens=5,226,479 now
renders "·" + "5.3M tokens used" instead of "100" + "3987% used".
Reported by @AvidFuturist.
Closes#1436.