- Remove duplicate mobile-close-btn from HTML
- Remove dead .mobile-close-btn CSS rules; unhide .close-preview at all viewports
- Change btnClearPreview tooltip from 'Hide workspace panel' to 'Close'
- Update tests across test_sprint41.py, test_sprint44.py, test_issue781.py,
and test_mobile_layout.py to match new single-button model
The boot IIFE unconditionally overwrote localStorage with whatever
settings.json had on the server. If the appearance autosave POST
ever failed (network glitch, transient error) the next page load
would revert the user's chosen theme/skin to the server's stale
defaults.
Fix: reconcile localStorage against the server on boot. When
localStorage carries a non-default skin or system theme (the user
explicitly chose something), localStorage wins and the fix pushes
those values back to the server. When localStorage is at defaults
(new browser / first visit), the server still wins.
Tested scenarios:
- User chose non-default skin, autosave failed → preserved + reconciled
- New browser, server has non-default skin → server value applied
- Normal use (autosave works) → unchanged behavior
Lets desktop users collapse the session-list sidebar to maximise the chat
area, without adding any visible UI affordance. Default appearance is
identical to master — only users who actively try to toggle (or know the
keyboard shortcut) ever see a difference.
## Behaviour (desktop only, ≥641px)
| State | Action | Result |
|------------------------------------|-----------------------|-----------------------------------------|
| Sidebar open, click active rail | Toggle | Sidebar collapses to width:0 |
| Sidebar open, click different rail | Normal switch | **Sidebar stays open** (no surprise) |
| Sidebar collapsed, click any rail | Expand + switch | Sidebar expands, then panel switches |
| Anywhere, Cmd/Ctrl+B | Toggle | Same as same-active-rail click |
| Mobile (<641px), any of the above | No-op | Mobile overlay behaviour unchanged |
Two discoverability paths, both opt-in. **No new visible buttons.** Users
who never click the active rail icon see zero UI change vs. master.
## Surface-minimal design
The behaviour is contained behind one extra arg on the rail/sidebar-nav
onclick: `switchPanel('chat',{fromRailClick:true})`. Without that flag the
function preserves master's behaviour exactly — every programmatic
`switchPanel(name)` callsite (commands, deeplinks, internal state changes)
is unaffected. The guard chain inside `switchPanel`:
opts.fromRailClick && _isDesktopWidth() && (
_isSidebarCollapsed() ? expandSidebar() :
prevPanel === nextPanel ? (toggleSidebar(true); return false))
is the ONLY new code path that can cause a collapse. Cross-panel clicks
fall through to the existing switch logic untouched.
## Polish from both source PRs
- **Click-active gesture** as the primary toggle (#1884 @jasonjcwu — the
genuine UX innovation; no extra button needed)
- **Cmd/Ctrl+B keyboard shortcut** (#1924 @spektro33; VS Code convention).
Guarded against firing when typing in INPUT / TEXTAREA / contenteditable
so the shortcut never steals from in-progress text editing.
- **Inline flash-prevention `<script>`** in `<head>` (#1924) sets
`data-sidebar-collapsed='1'` on `<html>` BEFORE the stylesheet loads,
so cold loads with a persisted-collapsed state paint correctly from
frame 0 with no flicker. Cleared by JS once the class system takes over.
- **Smooth slide animation** via `.24s cubic-bezier(.22,1,.36,1)`
(#1924, mirrors the existing workspace-panel collapse on the right)
- **`aria-expanded` mirrored** on the active rail button (#1884) so
screen readers announce open/collapsed transitions.
- **`body.resizing` transition-suppression** (#1884) keeps the drag-resize
cursor instant — no animation during a width-resize gesture.
- **bfcache `pageshow` re-sync** (#1884) — if another tab toggled the
sidebar while this page was frozen, bring it in line on restore.
## Drops vs. #1924
- No persistent rail "toggle sidebar" button (Nathan: keep the UI stealth)
- No close-X button in chat panel head (same reason)
- No i18n keys for the dropped buttons
## What did NOT change
- 22 rail/sidebar-nav `onclick` handlers gained the `{fromRailClick:true}`
arg — function-call shape, invisible to users
- 1 inline `<script>` in `<head>` (flash prevention) — invisible
- 5 lines of CSS — invisible unless someone collapses
That's the entire visible-UI delta. **23 ins / 22 del on `index.html`,
all string-replace.**
## Verification
- 5,151 pytest passing including a new 34-test structural suite covering
every contract (CSS rules, JS functions, fromRailClick guard, legacy
proxy forwarding, flash-prevention `<script>` ordering, mobile
exclusion via :not(.mobile-open) selector, aria-expanded sync).
- Live browser walkthrough at 1280px verified:
- Default boot state identical to master (sidebar open, width 300px)
- Click active rail → collapse (width 1, opacity 0, translateX -14px,
localStorage='1', aria-expanded=false). Panel unchanged.
- Click active rail again → expand back to width 300, aria=true
- Click DIFFERENT rail → normal switch, sidebar stays open (legacy-
preserving case, verified explicitly)
- Click rail while collapsed → expand + switch in one gesture
- Cmd+B toggles correctly
- Cmd+B inside `<textarea>` → suppressed (defaultPrevented=false)
- Reload with collapsed state persisted → restores without flash
- Mobile simulation (matchMedia returns false for min-width:641px):
same-active-rail click is no-op, Cmd+B is no-op, sidebar stays at 300px
Co-authored-by: jasonjcwu <jasonjcwu@users.noreply.github.com>
Co-authored-by: spektro33 <spektro33@users.noreply.github.com>
Closes#1884Closes#1924
- Session.composer_draft field: {text, files} stored in session JSON
- POST+GET /api/session/draft endpoint for save/load
- loadSession: save draft before switch, restore from S.session.composer_draft
- textarea input: debounced 400ms auto-save to server
- send(): clear draft after message is sent
- lockComposerForClarify(): save draft before card locks composer
- _restoreComposerDraft: clears textarea when target has no draft, guards
against stale responses racing new session loads, exact text comparison
- Session.compact(): includes composer_draft in response
- Fix: use handler.command instead of parsed.method (ParseResult has no .method)
Co-authored-by: Minimax <noreply@minimax.io>
Conflict resolution: both #1928 (session jump buttons) and #1929 (endless
scroll) add their own settings/UI/i18n keys. Resolved by keeping both —
the features are independent opt-in toggles.
Stage 311 maintainer-side enhancements on top of @jasonjcwu's PR #1782,
addressing browser-verified issues + extending coverage to high-traffic
icon buttons:
(1) Clear native title when custom data-tooltip is present (the core bug fix):
- static/i18n.js: when data-i18n-title runs against an element that has
data-tooltip, sync data-tooltip AND removeAttribute('title'). Without
this, the slow ~1.5s native browser tooltip co-fires alongside the
fast custom CSS tooltip — exactly the bug #1775 reports.
- static/ui.js _applyDashboardStatus: same treatment for the dashboard
rail/mobile buttons (was setting btn.title=warning unconditionally).
- static/boot.js: added _setButtonTooltip() helper, replaced 6 direct
.title assignments (workspace toggle/collapse/clear, voice dictate,
voice mode active/inactive) with calls through the helper.
(2) Extend coverage to high-traffic icon buttons in static/index.html:
- Composer area (side tooltip): btnAttach, btnMic, btnVoiceMode,
btnWorkspacePanelToggle, btnSend.
- Workspace panel header (bottom tooltip): btnCollapseWorkspacePanel,
btnUpDir, btnNewFile, btnNewFolder, btnRefreshPanel, btnClearPreview.
- All 11 buttons gain has-tooltip[--bottom] class and data-tooltip,
lose their native title=. Total covered surfaces: rail (12), sidebar
nav-tabs (12), panel-head (31), composer/workspace icons (11) = 66.
(3) CSS polish (browser-verified visible improvement):
- z-index 60 → 1500/1501 so the tooltip clears all sidebar/panel
stacking contexts. Earlier verification showed the tooltip overlapping
the Filter conversations search input.
- background: var(--bg-strong, ...) → var(--surface) (solid #1A1A2E
instead of falling back via undefined cascade).
- color: var(--text, var(--accent-text)) → var(--text) (solid warm white
#FFF8DC instead of gold which clashed at body-text size).
- border: var(--accent-bg-strong) → var(--border) (#2A2A45 solid
instead of gold at 0.15 alpha — the old border was barely visible
and the arrow ::before triangle was invisible).
- shadow: 4px/0.45 alpha → 6px/0.55 alpha + 0 0 0 1px ring fallback.
- Added 150ms hover-onset delay (matches Cygnus's spec in #1775); 0s
dismissal-delay so quick mouse-aways don't leave the tooltip behind.
- Fixed has-tooltip--bottom arrow direction: was pointing down (wrong),
now points up at the trigger (border-color order corrected).
- Bumped offsets: side tooltip 10px → 12px (clearance from icon edge),
bottom tooltip 8px → 10px.
(4) Test fixes (the 2 CI failures):
- tests/test_cron_refresh_button_835.py: assertion accepts either
title= or data-tooltip= per #1775 (was hardcoded title=).
- tests/test_mobile_layout.py::test_profiles_sidebar_tab_present:
regex tolerant to additional utility classes (has-tooltip).
(5) Regression tests added to tests/test_css_tooltips.py:
- test_native_title_cleared_when_custom_tooltip_present: pins the
removeAttribute('title') call so we don't regress to dual tooltips.
- test_native_title_path_preserved_for_non_tooltip_elements: pins the
el.title fallback for elements without data-tooltip.
Browser-verified: all 72 has-tooltip elements have zero native title at
runtime (was 94 with native, 2 stuck via dashboard JS path).
Co-authored-by: Jason Wu <jasonjcwu@users.noreply.github.com>
The Mac Swift app (hermes-webui/hermes-swift-mac) and any other native
WKWebView wrapper need the active theme background to keep AppKit
chrome (tab bar, title bar, traffic-light area) in sync with the page.
The current Mac approach pixel-samples the page via
elementsFromPoint, which is fragile against modals/lightboxes/file-tree
overlays — any opaque overlay over a sample point can poison the
chrome colour for the entire app. (See swift-mac issue #70.)
Surface the active theme's background as the canonical, overlay-resistant
source of truth via <meta name="theme-color">:
- Two static prefers-color-scheme variants in <head> for browsers that
read theme-color before any JS runs (mobile Safari, PWAs).
- One id="hermes-theme-color" runtime tag with an inline pre-paint
seed script that reads localStorage hermes-theme so the meta tag
is correct on first paint, before boot.js loads.
- New _syncThemeColorMeta() helper in static/boot.js that reads
getComputedStyle(html).getPropertyValue('--bg') and writes it into
the runtime meta tag. Called from _setResolvedTheme (both branches —
prism-loaded and prism-absent) and from _applySkin so every theme
toggle and skin switch updates the meta tag.
Reading --bg via getComputedStyle means each skin (Default, Sienna,
Sisyphus, Charizard, etc.) reaches the meta tag with its distinct
background — no per-skin lookup table to drift.
Browser-verified end to end on port 8789:
- light + default → meta=#FEFCF7 (matches --bg)
- light + Sienna → meta=#FAF9F5 (skin's distinct bg)
- dark + Sienna → meta=#1F1E1C (skin's dark variant)
10 regression tests added in tests/test_theme_color_meta_bridge.py
covering: static media variants present, runtime id stable, pre-paint
seed reads localStorage, helper defined and reads computed --bg,
helper targets known id, both _setResolvedTheme branches call sync,
_applySkin calls sync, root --bg defaults still match.
Companion PR coming on hermes-webui/hermes-swift-mac to switch the
theme bridge from elementsFromPoint pixel-sampling to reading
document.querySelector('meta[name="theme-color"][id="hermes-theme-color"]').content.
Refs hermes-webui/hermes-swift-mac#70.
Root page loads should not automatically project a localStorage-saved running session into the active pane. Keep explicit /session/<sid> behavior unchanged while leaving the saved session discoverable from the sidebar.
(cherry picked from commit bb60cf21d911a84e285363bcecf46fb441181fb9)
On some setups the localStorage quota is exhausted; the bare setItem
call throws an unhandled DOMException that breaks model selection and
prevents the chat UI from loading.
Wrap both call-sites (boot.js model-select onChange, onboarding.js
_saveOnboardingDefaults) in try/catch so the error is logged to the
console as a warning instead of surfacing as a fatal exception.
Fixes: 'Failed to execute setItem on Storage: Setting the value of
hermes-webui-model exceeded the quota.'
When the clipboard carries both text and an image (rich-text sources like
Notes, Word, Slack, browser selection attach a rendered preview alongside
the plain text), the paste handler in static/boot.js unconditionally
called e.preventDefault() and routed the image into addFiles(), silently
discarding the text payload.
Fix:
- Detect text in the clipboard via items[].kind === 'string' &&
(type === 'text/plain' || type === 'text/html'). When present, return
early so the browser's default text-paste runs.
- Tighten the image filter to kind === 'file' && type.startsWith('image/')
so string items advertising an image MIME (e.g. text/html with an
embedded data URI) are not misclassified as a true screenshot paste.
Pure-screenshot paste (image-only clipboard, e.g. Cmd+Shift+Ctrl+4 on macOS)
is unchanged.
Adds tests/test_1620_paste_text_with_image.py with 6 static-analysis checks
on the handler shape, matching the pattern of test_issue1095_pasted_images.py.
When a user disables 'Hands-free voice mode' in Settings while voice
mode is active, the button hides but the SpeechRecognition keeps
running — the user can't stop it because the button is invisible.
Fix: _applyVoiceModePref() now checks if voice mode is active and
calls _deactivate() when the pref is toggled off. Move
_voiceModeActive declaration above the function to avoid TDZ.
Also removes a duplicate window._applyVoiceModePref assignment.
Composer footer rendered two near-identical mic icons whose tooltips both
said "Voice input" — push-to-talk dictation and hands-free voice mode were
visually indistinguishable. Researched how ChatGPT/Claude/Gemini solve the
same problem and adopt the industry convention.
Changes:
- btnVoiceMode now uses Lucide audio-lines (6 vertical bars), the
universal voice-conversation glyph. Also registered in LI_PATHS.
- Distinct localized tooltips: voice_dictate ("Dictate") and
voice_mode_toggle ("Voice mode"), with active-state flips
(voice_dictate_active "Stop dictation", voice_mode_toggle_active
"Exit voice mode"). Legacy voice_toggle key removed (it resolved to
"Voice input" in every locale and caused the duplicate-tooltip bug).
- Voice mode is opt-in via Settings -> Preferences ->
"Hands-free voice mode button" (default off). Dictation mic stays
visible by default, unchanged. localStorage-backed; panels.js onchange
calls window._applyVoiceModePref() so the button appears/disappears
immediately without reload.
- 17 regression tests pin: distinct titles, audio-lines glyph, all 4
new keys in all 9 locales, removal of stale voice_toggle, English
labels match convention, pref gating (no unconditional display=''
left in boot.js), Settings checkbox + i18n, panels.js wiring,
active-state tooltip flips.
Browser-verified on port 8789: default state shows 1 mic; enabling
the pref makes the audio-waveform button appear live; tooltips read
"Dictate" and "Voice mode" distinctly.
Closes#1488
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)
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)
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>
Two unrelated UX bugs, both small surgical fixes with regression tests.
Issue #1432 — "+" button doesn't open new chat during streaming
================================================================
Reported by @Olyno: clicking "+" after sending a first message keeps
redirecting to the same chat instead of opening a new blank conversation,
making parallel chats impossible until the first response finishes.
Root cause:
static/boot.js:691 (and the Cmd/Ctrl+K branch at :844) had an empty-session
guard from #1171 that skipped newSession() when message_count===0:
if(S.session && (S.session.message_count||0)===0){
$('msg').focus(); closeMobileSidebar(); return;
}
But during the first user turn of a brand-new session, message_count is
still 0 server-side because the user message hasn't been merged into
s.messages yet. The guard treated that as "empty" and silently dropped
the click, blocking parallel chats for the entire stream duration.
Fix:
Tighten the predicate to also exclude in-flight state:
if(S.session
&& (S.session.message_count||0)===0
&& !S.busy
&& !S.session.active_stream_id
&& !S.session.pending_user_message){
$('msg').focus(); closeMobileSidebar(); return;
}
Same predicate applied to the Cmd/Ctrl+K handler at :844. The in-flight
signal (active_stream_id || pending_user_message) is the same one
_restoreSettledSession() in messages.js:1081 already uses to decide
whether a session is "settled" — keeping both call sites aligned.
Verified end-to-end: with S.busy=true and pending_user_message set, the
old guard returned `block=true` (= the bug), the new guard returns
`block=false` (= fixed). With a truly empty session (no busy, no pending),
both old and new guards still block — preserving #1171 behavior.
Issue #1423 — Profile name field auto-capitalizes typed values
==============================================================
Self-reported (Mac app, May 1 2026): typing `hello` into the New Profile
"Name" field shows `Hello` after blur/autofill, contradicting the
"Lowercase letters, numbers, hyphens, underscores only" hint right next
to it. The form lowercases on submit so stored data is correct, but the
displayed value during typing is misleading.
Root cause:
static/panels.js:2532 had only autocomplete="off":
<input type="text" id="profileFormName"
placeholder="..." autocomplete="off" required>
Missing three attributes that actually prevent the misbehavior:
- autocapitalize="none" — mobile keyboards (iOS Safari, Android Chrome,
WKWebView in the Mac app) auto-capitalize the first letter without it
- autocorrect="off" — Safari runs autocorrect on blur, can rewrite hello→Hello
- spellcheck="false" — desktop browsers may run spellcheck on blur
Fix:
Add the three attributes to profileFormName. Also added to
profileFormBaseUrl since URLs are similarly bad targets for
autocapitalize/autocorrect. profileFormApiKey is type="password" and
already has correct browser behavior.
Verified end-to-end against the live DOM: openProfileCreate() →
getElementById('profileFormName').getAttribute(...) returns the new
attributes correctly, with required preserved.
Tests
-----
3648 passed, 2 skipped, 3 xpassed (was 3640 — added 8 new regression tests
in test_1432_newchat_and_1423_profile_input.py).
One pre-existing test had to be widened: tests/test_mobile_layout.py
test_new_conversation_closes_mobile_sidebar grabbed only the first 500
chars of the btnNewChat handler block to scan for closeMobileSidebar.
The new comment block pushed closeMobileSidebar past that window even
though both calls are still present. Bumped the window to 1500 chars
and the shortcut-block lines from 12 to 24 to match the multi-line guard.
Closes#1432Closes#1423
Reported by @Olyno (#1432, GitHub)
Opus pre-release advisor caught 4 issues in stage-255 (#1390 + #1405):
1. MUST-FIX: api/rollback.py path-traversal — _checkpoint_root() / ws_hash /
checkpoint did NOT normalize Path() / "../escape", so an authenticated
caller could read or restore from another allowlisted workspace via
../<other-ws-hash>/<sha>. New _validate_checkpoint_id() regex-guards
with ^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$ and rejects . and .. literals.
Both get_checkpoint_diff and restore_checkpoint validate.
2. SHOULD-FIX: redact_session_data perf cliff — the new api_redact_enabled
toggle in #1405 called uncached load_settings() per string, recursed
across messages[] and tool_calls[]. For a 50-message session: hundreds
of disk reads per /api/session response. Now read once at the top and
thread _enabled through via private kwarg.
3. SHOULD-FIX: voice-mode wrong-session TTS — the patched autoReadLastAssistant
fires globally; if the user navigated to a different session between
sending and stream completion, TTS would speak the wrong session\\s reply.
New _voiceModeThinkingSid closure captures S.session.session_id at
thinking-time; _speakResponse bails to _startListening() on mismatch.
4. NIT: rollback._inspect_checkpoint had bare Exception in the except tuple
alongside specific catches, swallowing everything. Now (TimeoutExpired,
OSError) only.
6 regression tests in test_v050255_opus_followups.py. Full suite: 3587 passed,
2 skipped, 3 xpassed.
- Point 4 (security): _resolve_workspace now validates against known workspaces
from workspaces.json to prevent arbitrary path write via restore endpoint
- Point 5 (voice mode): bail out of voice mode on not-allowed, service-not-allowed,
and audio-capture errors instead of infinite retry loop
- Point 1 (locale coverage): added ~40 new English keys as placeholders with
TODO:translate comments in zh, zh-Hant, ko, ru, es, de, pt locales
- Point 2 (test fix): tightened test regex to anchor on branch-indicator class
to avoid collision with _sessionLineageKey helper
- Point 3 (test fix): accept both inline and parentEl variable forms for
body.appendChild pattern in pinned indicator test
All 6 previously failing tests now pass.
Persist session model_provider separately from model IDs so active/default provider selections like gpt-5.5 remain bare while routing through OpenAI Codex. Keep @provider:model for picker disambiguation and runtime bridging, and preserve explicit OpenRouter plus custom/proxy base_url routing.