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>
- Add .has-tooltip CSS utility class with 300ms delay (vs ~1500ms native)
- Position-aware: right side for rail buttons, bottom for nav/panel buttons
- Arrow indicator pointing back at trigger element
- :focus-visible support for keyboard accessibility
- prefers-reduced-motion: no animation for users who opt out
- Replace native title="" with data-tooltip="" on all rail-btn, sidebar
nav-tab, and panel-head-btn elements in index.html
- Sync data-tooltip via data-i18n-title handler for locale switching
- 17 tests covering HTML coverage, CSS class definitions, and i18n sync
Closes#1775
Issue #1764 asked for a much larger surface (Reveal + Copy-path on
every UI surface that references a file path, plus Rename in session
menus). Per Nathan's curation we ship only the three highest-leverage
pieces in this PR — they cover the three concrete user-visible
frictions Cygnus reported, and leave the broader sweep for follow-up.
## 1. Copy file path in workspace tree right-click menu
The tree's right-click already had Rename and Reveal in File Manager.
Reveal is slow when the user just wants the path string for a
terminal/editor — and there was no Copy-path action anywhere.
Added "Copy file path" between Reveal and Delete. It POSTs to a new
`/api/file/path` endpoint that resolves the relative tree-rooted path
into the absolute on-disk path (the frontend can't compute it because
only the server knows the workspace root) and writes the result to
the OS clipboard via `navigator.clipboard.writeText()`. Falls back to
the legacy execCommand pattern on browsers where the modern Clipboard
API is gated.
The new endpoint deliberately does NOT require the target to exist:
copy-path on a recently-deleted file is still useful (paste into a
terminal to investigate). `safe_resolve` continues to gate path
traversal — the test suite pins this with a `../../../../../etc/passwd`
attempt that 400s.
## 2. Rename in session three-dot menu
Cygnus's specific ask: double-click rename in the sidebar is timing-
sensitive — the first click frequently registers as "open the chat"
before the second click arrives, so users open the conversation when
they meant to rename it. Putting Rename in the menu eliminates the
timing entirely.
Added Rename as the FIRST item in `_openSessionActionMenu` (above
Pin). It reuses the existing `startRename` closure attached to each
session row — no duplicated state, no second API call out of band
with the double-click path. Mechanism: the row builder now stores
`el._startRename = startRename` and `el.dataset.sid = s.session_id`,
so the menu can find the row by data-sid and call its closure
directly. This keeps all the `_renamingSid`/`oldTitle`/`applyTitle`
bookkeeping single-sourced.
Read-only imported sessions skip the menu item via the same
`_isReadOnlySession` gate the closure already uses.
## 3. Reveal-failed toast includes the resolved server-side path
Cygnus posted a screenshot of a "Failed to reveal: not found" toast
that dropped the path entirely. Without it the user can't tell which
file the system expected — useful when a stale session row still
references a deleted file.
Server-side fix in `_handle_file_reveal`: instead of returning
`bad(handler, "File not found", 404)`, return
`bad(handler, f"File not found: {target}", 404)` where target is the
resolved absolute path. Frontend toast also defends against err with
no .message: `(err.message||err)` instead of `err.message` alone.
Verified live: a missing-file reveal now produces:
Failed to reveal: File not found: /home/hermes/workspace/missing-xyz.txt
Cygnus's exact diagnostic-friction is gone.
## Tests
* tests/test_1764_context_menu_essentials.py (new)
- 13 source-level pinning tests
- 6 live HTTP behaviour tests against the conftest test server
* tests/test_1466_sidebar_cancel_clarify.py
- Two assertion-window bumps (3200→4400, 3600→4800) to accommodate
the new Rename action prepended to _openSessionActionMenu. The
test relied on a fixed-byte-window function-body slice — comments
added explaining why the bumps were needed.
* All 9 locales got translations for the 5 new keys
(copy_file_path, path_copied, path_copy_failed, session_rename,
session_rename_desc) — locale parity tests pass.
## Verification
Full pytest suite: 4671 passed, 2 skipped, 3 xpassed (matches
pre-change baseline).
Live browser verification on port 8789:
- Right-click .git folder in workspace tree → menu shows
Rename / Reveal in File Manager / Copy file path / Delete (red).
- Click Copy file path → clipboard gets "/home/hermes/workspace/.git",
toast confirms "File path copied to clipboard".
- Open session three-dot menu → Rename conversation appears first
with pencil icon, followed by Pin / Move / Archive / Duplicate /
Delete in the same order as before.
- Trigger reveal on a non-existent file → toast reads
"Failed to reveal: File not found: /home/hermes/workspace/<filename>".
The resolved server-side path is now visible in the failure.
Refs nesquena/hermes-webui#1764.
PR #1742 removed the only consumer of the `profiles_busy_switch` toast
(the frontend S.busy-based early return in static/panels.js — which was
shown when profile switch was blocked by an active stream). The 9 locale
entries are now orphaned: they exist in static/i18n.js but no code path
references them.
Opus stage-304 advisor flagged this as a low-priority SHOULD-FIX
("file as a v0.51.x cleanup ticket, don't block the release"). Absorb-
in-release per the absorb-default policy: ≤10 LOC and clearly defensive.
Removed entries: en, ja, ru, fr, de, zh, zh-Hant, pt, es. Locale parity
tests still pass (no key is missing; we removed it from English first).
4596 tests still pass.
Closes the remaining gaps to first-party Hermes Agent dashboard parity:
multi-board CRUD on /api/kanban/boards and a real-time event stream over
Server-Sent Events. Builds on top of #1660 (review-feedback hardening).
== Multi-board ==
Five new endpoints mirror the agent dashboard plugin contract verbatim
(plugins/kanban/dashboard/plugin_api.py) so a single CLI / gateway slash
command / dashboard / WebUI all share the same active-board pointer:
GET /api/kanban/boards
POST /api/kanban/boards
PATCH /api/kanban/boards/<slug>
DELETE /api/kanban/boards/<slug>
POST /api/kanban/boards/<slug>/switch
All existing endpoints accept ?board=<slug> (and writes also accept
'board' in the JSON body) — query takes precedence over body. The slug
travels through the kanban_db library which already had multi-board
support; the bridge is mostly thin wrappers around create_board /
remove_board / list_boards / set_current_board / get_current_board.
The default board is protected from deletion. Slugs are normalised
through kb._normalize_board_slug() with path-traversal rejection.
Archive is the default for DELETE; ?delete=1 hard-deletes.
Frontend gets a 'Default ▾' switcher pill in the panel header. The menu
lists every board (current first), per-status total badges, plus three
actions (New / Rename / Archive). Create + rename use the same modal
with a slug auto-derived from the name. Archive routes through the
existing showConfirmDialog with a clear 'tasks remain on disk and the
board can be restored from kanban/boards/_archived/' message.
Active-board state is persisted to localStorage so a refresh stays put.
The on-disk pointer in kanban/current is the cross-process source of
truth, kept in sync via POST /boards/<slug>/switch.
== SSE event stream ==
GET /api/kanban/events/stream is a long-lived Server-Sent Events feed
that mirrors the agent dashboard's WebSocket /events contract. The
WebUI uses SSE rather than WebSocket because (1) the existing transport
is BaseHTTPServer, not async — WS would require a significant refactor
or a hijack-the-socket hack; (2) SSE is the right tool for unidirectional
server-pushed event streams; (3) browsers auto-reconnect on drop;
(4) the existing /api/approval/stream and /api/clarify/stream patterns
are proven and easy to copy.
The handler polls task_events at 300ms (matching the agent dashboard's
WebSocket poll cadence) so write-to-receive latency is identical.
Heartbeats every 15s prevent proxy/CDN reaping. Hard cap of 200 events
per batch.
Frontend uses EventSource by default and falls back to 30s HTTP polling
after 3 SSE failures. A 250ms debounce coalesces bursts of N events
into a single board re-fetch. Stream is torn down when the user leaves
the Kanban panel.
== Bugs fixed during build ==
(1) read_only=True legacy lie. _board_payload, _events_payload,
_task_log_payload, and the no-change short-circuit all hardcoded
read_only=True from the read-only-bridge era of #1645. Bridge has
been writable since #1649 — flag now matches reality.
(2) Modal + dropdown menu transparent backgrounds. The PR stack used
var(--panel) which is undefined in the WebUI design system (uses
--surface, --bg, gradient panels). Replaced with the same gradient
+ accent border pattern used by the .app-dialog overlay.
(3) Archive race. kb.connect(board=<slug>) auto-materialises the
directory + sqlite on first call, so any in-flight SSE poll on a
board mid-archive would silently un-archive it by re-creating the
directory. Two-layer fix: (a) frontend stops the SSE stream BEFORE
the DELETE call, restarts on failure; (b) bridge's _kanban_sse_fetch_new
checks kb.board_exists() before connect(), returning empty results
when the board is gone.
(4) Save vs. Cancel button visual hierarchy. Both rendered as identical
secondary buttons in the modal. Save now uses the .primary class
with accent-tinted gold styling.
(5) Mobile viewport gaps. Added 9 rules under @media (max-width: 640px)
covering the switcher button (smaller padding/font), name truncation
(max-width:140px), menu sizing (min(280px, 100vw - 24px)), modal
padding, and inline-row stacking.
== Tests ==
+45 new tests across two files. Bridge tests: 18 covering board CRUD
endpoints, slug validation, default-board protection, dispatcher routing,
board isolation (verified via connect() spy), and 3 SSE tests including
a worker-thread integration test with threading.Event watchdog. UI static
tests: 11 covering switcher markup, modal markup, JS handler presence,
REST verb usage, board-param plumbing, localStorage persistence,
showConfirmDialog usage, EventSource subscription, polling fallback,
panel-switch teardown, and 250ms debouncing.
Bridge tests: 18 → 36 (+18 multi-board, +3 SSE)
UI static tests: 15 → 26 (+11)
Total kanban: 33 → 63
Full repo test suite: 4351 passed, 0 regressions.
== Live verification ==
End-to-end browser walkthrough on port 8789:
- Create Sprint 12 + Backlog via modal: switcher updates ✓
- Switch between boards: count isolation correct ✓
- Add task on Sprint 12 via API: SSE delivers in 400ms ✓
- 5-task burst: 250ms debounce coalesces to single render ✓
- Rename board via modal: switcher label updates ✓
- Archive board: confirm dialog → board moved to _archived/, no zombie
directory (race fix verified) ✓
- Zero JS errors throughout 11-step flow
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
Settings password silently no-opped when HERMES_WEBUI_PASSWORD was set:
the env var takes precedence in api.auth.get_password_hash(), but the UI
happily POSTed _set_password and returned a green "Saved" toast while
every subsequent login still required the env-var password. Same for
Disable Auth (_clear_password=true).
Backend (api/routes.py):
- GET /api/settings now exposes password_env_var: bool so the UI knows
the field is shadowed.
- POST /api/settings refuses _set_password and _clear_password with HTTP
409 + a clear message naming HERMES_WEBUI_PASSWORD when the env var is
set. Short-circuits BEFORE save_settings() so settings.json is not
touched.
Frontend (static/index.html, static/panels.js, static/i18n.js):
- Added settingsPasswordEnvLock banner div in the System pane.
- panels.js reads settings.password_env_var, disables the password field,
swaps in a localized "locked" placeholder, reveals the banner, and
hides the Disable Auth button (its POST would 409 anyway).
- New i18n keys password_env_var_locked and password_env_var_locked_placeholder
added to all 9 locales (en, ja, ru, es, de, zh, zh-Hant, pt, ko).
Tests:
- tests/test_issue1560_password_env_var_lock.py: requirement-pinning
(handler exposes flag, 409 on set/clear, banner div, panels.js wiring,
i18n in all 9 locales, env var name in messages, live HTTP smoke when
env unset).
- tests/test_1560_password_env_var_no_op.py: behavioral via FakeHandler
(real status codes for env-set/unset/blank, settings.json hash unchanged
after 409, panels.js disable+banner+placeholder+disable-auth-hidden).
Both files run clean: 23 passed in 2.04s. test_issue1139_password_remote.py
unaffected (4/4 still pass).
Pre-release Opus advisor caught a gap: PR #1551 v2 added the
reveal_in_finder/reveal_failed keys to en/ja/ru/es/de/zh/zh-Hant/ko but
omitted the pt block. Locale parity tests for ja/ru/es/zh/ko all pass
because they run en_keys - locale_keys parity assertions, but pt and de
have no general parity test — so the pt gap would silently ship with
Portuguese users seeing English fallback for the new context-menu item.
Translations:
pt: Mostrar no gerenciador de arquivos / Falha ao mostrar:
Trivial 2-LOC absorb. Filing follow-up issue for pt/de cross-locale
parity test mirroring the existing 5.
Pre-fix, the wizard rejected an empty api_key for every provider in
_SUPPORTED_PROVIDER_SETUPS — including lmstudio, ollama, and custom,
which run keyless on the vast majority of local installs. The agent's
LMSTUDIO_NOAUTH_PLACEHOLDER substitution at chat-time was the workaround
for the no-auth case, but the wizard side rejected the empty input first.
Users had to type random gibberish into the API key field to clear the
form — the third sub-bug from #1420 that the prior commit's PR description
explicitly punted to a follow-up.
Surfaced by Nathan during PR review: "I think it's too weird for users
to have to type a string into the API key field, right?" Yes — and the
probe (#1499) makes the cleanest fix strictly better: we accept empty
keys, and the probe gives instant feedback ("Connected. 2 model(s)
available." for keyless servers, "401" for auth-required servers).
Backend changes
---------------
* `api/onboarding.py` — `_SUPPORTED_PROVIDER_SETUPS` gains
`key_optional: True` for `lmstudio`, `ollama`, `custom`. Cloud
providers (openrouter, anthropic, openai, gemini, deepseek, …)
remain key_required.
* `apply_onboarding_setup` skips the "{env_var} is required" check
when `key_optional` is set AND no key is supplied. No write to .env
for the empty-key case (no `LM_API_KEY=*** placeholder lying in the
user's .env`).
* `_status_from_runtime` reports `provider_ready=True` for key_optional
providers based on `requires_base_url` alone, so the wizard doesn't
refire on the next page load just because there's no api_key. Cloud
providers still need a key for provider_ready=True.
* `_build_setup_catalog` exposes the `key_optional` flag to the frontend.
Frontend changes
----------------
* `static/onboarding.js` — new `_renderOnboardingApiKeyField()` helper.
For key_optional providers:
- Label: "API key (optional)"
- Placeholder: "Leave blank for keyless servers"
- Inline italic muted help: "Most LM Studio / Ollama / vLLM installs
run keyless — leave this blank if your server doesn't require
authentication. Use the Test connection button to verify."
For cloud providers: unchanged (label "API key", standard placeholder,
no help block).
* The api-key input also now triggers `_scheduleOnboardingProbe()` on
oninput, so changing the key re-runs the probe — handles "the server
rejected my empty key with 401, let me add one and retry."
* `static/i18n.js` — 3 new keys × 9 locales (canonical English in `en`,
English fallback with `// TODO: translate` markers in the other 8).
* `static/style.css` — `.onboarding-api-key-help` rule for the muted
italic helper paragraph.
Verified end-to-end on port 8789
--------------------------------
Spun up an isolated test server + a mock LM Studio at
`127.0.0.1:11234/v1/models`. Stepped through the wizard:
* Picked LM Studio → field label flipped to "API key (optional)",
placeholder showed "Leave blank for keyless servers", help text
rendered in italic muted gray below.
* Switched to Anthropic → label reverted to "API key", help text
disappeared. Visual hierarchy correct.
* Left api_key blank, set base_url to the mock, clicked Test connection
→ green "Connected. 2 model(s) available." banner. Probe-discovered
models populated the workspace-step dropdown.
* Continued through to the finish step. config.yaml written with
provider/model/base_url. **`.env` does NOT exist** — no placeholder
string written. `chat_ready: true`, `state: ready`.
* Vision tool confirmed the visual hierarchy: subtle italic help
reads as documentation, prominent green banner pops as status.
Tests
-----
`tests/test_issue1499_keyless_onboarding.py` — 16 tests in 3 classes:
TestKeyOptionalProviderSchema (5)
- lmstudio / ollama / custom declare key_optional=True
- openrouter / anthropic / openai do NOT (regression defense)
- setup catalog exposes the flag
TestKeylessOnboarding (6)
- lmstudio / ollama / custom: empty api_key accepted, no .env write
- openrouter / anthropic: empty api_key still rejected
- lmstudio with explicit key still writes .env (regression defense)
TestKeylessChatReady (5)
- lmstudio / ollama: provider_ready=True with no key
- custom: provider_ready=True with key+base_url, False without base_url
- openrouter: provider_ready=False with no key (regression defense)
- End-to-end get_onboarding_status reports chat_ready=True
Full suite: 3901 → 3917 passing (+16 from this commit; +22 cumulative
from the PR's earlier commit). 0 failures.
Closes#1499 (all three sub-bugs from #1420 now addressed)
Addresses both #1499 (onboarding wizard never probes the configured base URL)
and #1500 (cross-tool env-var name divergence between webui and agent CLI).
Surfaced together because they're both LM-Studio onboarding bugs that pile
on top of each other — fixing only one leaves the broken UX.
#1499 — Onboarding wizard probes <base_url>/models before persisting
Pre-fix, `apply_onboarding_setup` accepted whatever `base_url` the user typed
without ever fetching `<base_url>/models`. @chwps's log timeline in #1420
showed the wizard finishing in 239ms with zero outbound HTTP — onboarding
silently persisted unreachable URLs and left users with empty model
dropdowns they had to populate by hand-editing config.yaml.
Backend:
* New `probe_provider_endpoint(provider, base_url, api_key, timeout=5.0)`
in `api/onboarding.py`. Stdlib-only (urllib + socket — no httpx dep).
Returns `{ok, models}` on success; `{ok: False, error: <code>, detail}`
on failure with stable error codes the frontend can switch on:
invalid_url, dns, connect_refused, timeout, http_4xx, http_5xx, parse,
unreachable. 256 KB response cap and 5s timeout keep a hostile or mis-
pointed endpoint from blocking the wizard.
* New `POST /api/onboarding/probe` route — thin JSON wrapper around the
function above. Same local-network gate as `/api/onboarding/setup`
because the body carries an `api_key` the user typed.
* The probe response is NEVER persisted. Only the user's typed selection
ends up in config.yaml; the probed model list just populates the
wizard's dropdown.
* SSRF: deliberately does NOT block private-IP ranges. The wizard is
gated behind WebUI auth and the legitimate target IS a local LM Studio
/ Ollama / vLLM server. A "block private IPs" SSRF defense would make
the feature useless for its primary use case.
Frontend:
* `static/onboarding.js`:
- New `ONBOARDING.probe` state ({status, error, detail, models, probedKey}).
- `_runOnboardingProbe()` — POSTs to /api/onboarding/probe, idempotent
& cached on (provider, baseUrl, apiKey).
- Debounced (400ms) on `oninput` of the base URL field.
- Explicit "Test connection" button.
- `nextOnboardingStep` blocks Continue at the setup step for any
provider with `requires_base_url=True` until the probe succeeds.
Same localized error renders inline.
* `static/i18n.js`: 13 new keys × 9 locales (canonical English in `en`,
English fallback with `// TODO: translate` markers in the other 8 —
same convention as v0.50.271 #1488 voice-buttons).
* `static/style.css`: probe banner + Test button styling (red-tinted
error variant, green-tinted success variant, neutral probing state).
Verified via manual repro on port 8789:
* connect_refused → red banner, helpful "from Docker, try the host IP"
hint, blocks Continue.
* DNS failure → red banner, "could not resolve host '...'", blocks Continue.
* Success against a mock /v1/models server → green banner, model dropdown
populates from the probed list, Continue advances normally.
#1500 — webui env var aligned with agent CLI (LM_API_KEY)
The webui has long used `LMSTUDIO_API_KEY` for LM Studio's API key in
both onboarding and Settings detection. The agent CLI runtime
(hermes_cli/auth.py:177-183) reads `LM_API_KEY`. So a user who configured
auth on their LM Studio instance got Settings → Providers reporting
has_key=True (because webui saw its own LMSTUDIO_API_KEY) but the agent
runtime ignored the key and fell back to LMSTUDIO_NOAUTH_PLACEHOLDER →
401 against the auth-enabled LM Studio server. Masked in practice for
the no-auth majority.
Picked Option B from the issue (defer to the agent — single source of
truth) but mitigated the migration cliff by reading the legacy name as
a fallback:
* `api/onboarding.py:_SUPPORTED_PROVIDER_SETUPS["lmstudio"]`:
- `env_var: "LM_API_KEY"` (canonical, what onboarding writes going forward).
- `env_var_aliases: ["LMSTUDIO_API_KEY"]` (read-only fallback for
pre-#1500 users so detection keeps working without forcing an
.env rewrite).
* `api/onboarding.py:_provider_api_key_present` reads aliases too.
* `api/providers.py:_PROVIDER_ENV_VAR["lmstudio"] = "LM_API_KEY"`.
* `api/providers.py:_PROVIDER_ENV_VAR_ALIASES["lmstudio"] = ("LMSTUDIO_API_KEY",)`
— new dict, used by `_provider_has_key` and `get_providers`'s
key_source resolution. Drops in cleanly when other providers later
rename their env vars too.
Verified:
```
before fix: webui writes LMSTUDIO_API_KEY → agent ignores it → 401 on chat
after fix: webui writes LM_API_KEY → agent picks it up → chat works
pre-#1500 .env with LMSTUDIO_API_KEY → still has_key=True in Settings
→ key_source='env_file'
```
Tests
* `tests/test_issue1499_onboarding_probe.py` — 17 tests:
3 invalid_url variants, dns, connect_refused, success (OpenAI shape),
success (bare-list shape), http_4xx, http_5xx, parse non-JSON, parse
wrong-shape, api_key authorization header passthrough, "probe must
not write to config.yaml or .env", PROBE_ERROR_CODES contract pin,
3 end-to-end route-level smoke tests against the live server fixture.
* `tests/test_issue1500_lmstudio_env_var_alignment.py` — 5 tests:
onboarding declares LM_API_KEY canonical with LMSTUDIO_API_KEY alias,
onboarding writes ONLY the canonical name, legacy env var still
detected post-migration, canonical takes precedence when both are
set, _provider_api_key_present reads aliases.
* `tests/test_issue1420_lmstudio_provider_env_var.py` — updated:
the original 5-test #1420 suite now pins LM_API_KEY as canonical
and LMSTUDIO_API_KEY as alias.
Full suite: 3879 → 3901 passing (+22), 0 failures.
Out of scope (explicitly NOT addressed here)
The third LM Studio onboarding sub-bug from #1420's thread — that
`apply_onboarding_setup` requires a non-empty api_key for lmstudio
even though most LM Studio installs run keyless — remains. The agent's
`LMSTUDIO_NOAUTH_PLACEHOLDER` substitution kicks in at runtime, but
the onboarding wizard rejects the empty-key case at submit. Fixing
this requires a UX decision (auto-write a sentinel? loosen the
required-key check for self-hosted providers?) and is left as a
separate follow-up.
Closes#1499Closes#1500
Co-authored-by: chwps <106549456+chwps@users.noreply.github.com>
Co-authored-by: AdoneyGalvan <25235323+AdoneyGalvan@users.noreply.github.com>
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)
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.
OAuth keys (oauth_codex_*, oauth_login_codex), session toolset keys
(session_toolsets_*), and usage_personality_none were missing from zh,
zh-Hant, ko, ru, es, de, pt locale blocks.
All keys added as English placeholders with '// TODO: translate' comments
to unblock locale coverage CI gates.
Fixes: CI failure on 4 locale coverage tests
- 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.
The Settings toggle label previously said 'Show CLI sessions' or 'Show
agent sessions', but the feature actually surfaces conversations from
CLI, Telegram, Discord, Slack, WeChat, and other non-WebUI channels.
- Rename i18n key: settings_label_cli_sessions → settings_label_external_sessions
- Rename i18n key: settings_desc_cli_sessions → settings_desc_external_sessions
- Update all 8 languages (en, zh, zh-TW, ru, es, de, pt, ko)
- Reorder channel examples by global adoption: Telegram, Discord, Slack
- Update HTML fallback text to match new English strings
Three small fixes from Opus review of the merged stage diff:
1. Strip 9 orphan wiki_* i18n keys (72 lines) from PR #1342 — leaked
from a different branch, zero references outside i18n.js.
2. /branch endpoint: reject non-string session_id with explicit 400
(was raising TypeError → generic 500 from get_session()).
3. /branch endpoint: reject negative keep_count with explicit 400
(Python slice semantics on negative produces 'all but last N',
confusing fork behavior).
Plus tests/test_v050253_opus_followups.py — 3 regression tests pinning
all three fixes.
Verified: 3558 pytest passing.
Fix: gate parent_session_id emission in compact() on truthiness so
sessions without a fork link don't leak parent_session_id: None and
break the v0.50.251 lineage end_reason gating in agent_sessions.py.
The /branch endpoint sets the field on saved forks; everything else
keeps the v0.50.251 sidebar lineage path as the canonical source.