- /api/git-info falls back to state.db metadata for sessions without a WebUI
sidecar (CLI, delegated subagents), mirroring /api/list, instead of 404ing.
- Approval and clarify fallback pollers stop on session_profile_mismatch 409
instead of re-requesting every 1.5s/3s; live cards stay standing.
- Drop <link rel=preload> for pwa-startup.js: the preload scanner resolves it
before the dynamic <base href> is written, so on /session/<id> it fetches
/session/static/... while the script loads /static/..., a wasted duplicate
request plus a 'preloaded but not used' warning on every load.
iOS Safari reports matchMedia('(any-pointer:fine)') as true on iPhone,
so _hasFinePointerCoexisting() wrongly vetoed the touch-device default
and plain Enter fell through to send().
- boot.js: _isTouchOnlyDevice() makes phone/tablet UA authoritative,
no longer vetoed by any-pointer:fine; iPadOS-as-Macintosh detected
via maxTouchPoints; media-query pair is fallback for non-phone touch
devices. Composer handler reads live window._sendKey on every keydown
so Settings changes take effect without reload.
- index.html: capture-phase interceptor removed; boot.js handler alone
covers the mobile newline case (initial _sendKey already synced from
localStorage synchronously, closing the async race).
- tests/test_send_key_preference_live_update.py: Playwright regression
test verifies all three send-key modes and all transitions work after
runtime preference change.
The Extensions settings pane (Gallery / Installed / Diagnostics) was the
last large user-facing surface still rendering hardcoded English:
- static/index.html carried the section meta, both loading placeholders,
the Copy diagnostics button, and the whole Trust-model explainer block
as plain HTML with no data-i18n attributes.
- static/panels.js built the runtime / diagnostics cards from template
literals with 14 untranslated row labels, a separate installed card,
asset and warning card titles, sidecar card fields (Origin / Health
path / Health URL / Proxy / Proxy path), the Enabled/Disabled and
true/false badges, and every empty-state string (None, No warnings,
No extension directory is configured., No manifest extensions are
installed in the configured bundle., No loopback sidecars declared.)
inline in JS.
Every one of those call sites now routes through t() and the plain
strings live in the locale bundles. data-i18n is used for the static
HTML (the same mechanism the other selects already use, per #7582
triage), and esc(t(...)) for the template-literal DOM built in
panels.js so no injected text can introduce markup.
Locale keys are added to all 15 shipped locale bundles (en, ru, it, ja,
es, de, zh, zh-Hant, pt, ko, fr, cs, tr, pl, vi), following the same
key-parity contract that the #7602 triage enforced for the
sessions_source keys: inserted right after each locale's identity
(_label:_speech_) line so the brace-bounded locale extraction still
finds the whole block, matching the same pattern upstream used to fix
the sessions_source parity. No locale falls back to English at runtime
because t() resolves every bundle.
tests/test_extensions_settings_panel.py: assertions that used to match
the inline English strings now match the corresponding locale keys, and
the state_unknown_ids hint asserts both the key is used and the raw
English is gone.
Verified:
- python3 -m pytest tests/ -k "i18n or extension or parity or japanese"
-q -> 496 passed
- node --check on the three touched static assets -> all valid
Fixes#7581. Same surgical slice as #7602 (linked from #7579).
* fix(appearance): don't let the boot script fabricate an explicit theme choice
`syncSettings` in static/boot.js is careful to let the server win on a first
visit. Its comment says why:
// empty (new-browser) state is indistinguishable from a user who chose
// the defaults. To avoid blocking server→client sync on first visit we
// only let localStorage override the server when it carries an explicit
// user-selectable theme value or a NON-DEFAULT skin.
The inline bootstrap in static/index.html defeats that. It resolves a theme with
`localStorage.getItem('hermes-theme')||'dark'` and then unconditionally writes the
result back:
localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);
On a brand-new browser that stores `hermes-theme=dark` before any request is made.
By the time syncSettings runs, `lsHasExplicitTheme` is true, so:
1. the server's `theme`/`skin` in SETTINGS_DEFAULTS (or settings.json) is ignored, and
2. the reconcile branch below POSTs the fabricated value back to /api/settings,
overwriting the stored setting it was supposed to be reading.
The net effect is that the server-side appearance default is unreachable: a fresh
browser always lands on dark and then persists it server-side. Deployments that
set a different default in SETTINGS_DEFAULTS never see it applied.
Fix: only rewrite localStorage when there was already appearance state to
normalise. A fresh visit now leaves localStorage untouched, so `lsHasExplicitTheme`
is false and syncSettings takes the server value exactly as its comment intends.
Deliberately keeps the existing normalisation. The write is not pointless — it
canonicalises legacy names (`solarized` → `dark` + `poseidon`). Gating on "was
there any prior state" rather than per-key preserves that: with `hermes-theme=solarized`
and no `hermes-skin`, the derived skin is still written, so the mapping survives
the next load. A per-key guard would have silently dropped it.
Verified by driving the bootstrap in node against a stubbed localStorage. Behaviour
is byte-identical to before for every input that has prior state — legacy
`solarized`, an explicit `light`+`mono`, and a skin-only `slate` all produce the
same painted result and the same localStorage. The only case that changes is the
empty one, which is the bug:
before {} → localStorage {hermes-theme: dark, hermes-skin: default} server locked out
after {} → localStorage {} server wins
Painting is unchanged throughout: a fresh visit still renders dark, since that is
still the fallback.
* test(appearance): pin absent vs explicit appearance state in the bootstrap
Adds the regression coverage asked for in review. Nothing previously
distinguished "no appearance stored" from "user chose these values":
tests/test_1003_appearance_autosave.py covers picker autosave and
tests/test_bugfix_sweep.py covers guarded storage access, but neither fails if
the bootstrap starts fabricating a choice again.
Executes the REAL bootstrap, lifted out of static/index.html, in node against a
stubbed localStorage and documentElement — the same harness used to verify the
fix, now committed instead of run by hand. Covers all three states the review
called out, plus the pre-paint assertion:
- both keys absent -> no setItem at all, AND the dark class is still applied
(the fix changes persistence only, never the paint)
- either key present -> both values normalised and persisted
- legacy names -> solarized/slate/monokai/nord/oled still migrate, and
the DERIVED skin is still written
That last group is why the guard is on the pair rather than per key: with
`hermes-theme=solarized` and no `hermes-skin`, the write of the derived skin is
the only thing that persists the mapping, so a per-key guard would silently drop
it on the next load.
Verified as a regression test, not a tautology: against the pre-fix
static/index.html, test_fresh_browser_writes_nothing and
test_guard_is_on_the_pair_not_per_key both FAIL, while the other nine still pass
— the unfixed bootstrap does still paint dark and does still persist
migrations, so only the buggy behaviour differs.
One harness note worth keeping: the driver and the extracted script are passed
as FILES (`node <driver> <script>`). Passing the script via `node -e ... --
<script>` shifts process.argv, so the driver eval'd the JSON scenario instead of
the bootstrap — and since `eval('{}')` is a valid empty block rather than a
syntax error, the empty-store case passed while executing nothing. _run() now
asserts the bootstrap produced some effect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(appearance): keep `var themes=` intact so the skin tests still find the block
CI caught this; review could not. `tests/test_sienna_skin.py` (x2) and
`tests/test_catppuccin_skin.py` locate the inline bootstrap with
init_script_idx = INDEX_HTML.find("var themes=")
end_idx = INDEX_HTML.find("</script>", init_script_idx)
init_block = INDEX_HTML[init_script_idx:end_idx]
Declaring `_hadAppearance` as the first `var` in that chain turned `var themes=`
into `,themes=`, so `find()` returned -1, `init_block` came out EMPTY, and three
assertions failed against the empty string — including "Default theme must remain
'dark'", which read as though this PR had changed the default. It had not: the
default is untouched, the block simply could not be located any more.
Splitting the declaration (`;var themes=` instead of `,themes=`) restores the
literal. Both are `var` in the same function scope, so behaviour is identical —
the regression module still passes unchanged, including the empty-store case
(no writes, dark class still applied) and all five legacy migrations.
Failed on shards 3 and 4 across 3.11/3.12/3.13 while the same shards passed on
master, which is what pinned it to this change rather than flake.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Hermes Agent <hermes@aip.de>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* feat(sidebar): add show_kanban_sessions toggle to hide kanban worker sessions
* fix(sidebar): kanban source-filter override + filtered-view cap (cron/webhook parity)
Gate re-review (Codex) found the kanban-hide addition missed cron/webhook
parity for the source-filter case:
- An explicit kanban source_filter now overrides the default hide (threaded
source_filter into _dedupe_cli_sidebar_sessions_for_api, normalized with
strip().lower() to match the loader), so a deliberate kanban view shows
those rows even with the toggle off. Scoped to the matching source only.
- A dedicated KANBAN_PROJECT_CHIP_LIMIT bounds a filtered kanban-only view,
mirroring CRON/WEBHOOK_PROJECT_CHIP_LIMIT.
Deliberately NOT changing the general exclude_sources: the original PR lets
kanban load on the source_filter=None path so the toggle-ON case works (the
hide is applied by _hide_from_default_sidebar only when the toggle is off).
Excluding kanban there would save the 20-row budget but break toggle-on
unless a bounded kanban merge is added (cron/webhook don't do that either) —
that query-budget optimization is deferred as a separate cross-cutting change.
The pinned-survives-hide finding is also out of scope (cron/webhook hide pinned
identically, confirmed by Codex). Adds bite-tested regressions.
Co-authored-by: vduke <vduke@users.noreply.github.com>
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Fable UX polish pass on the gateway approval counter:
- Move the crude inline style (font-size:0.75em;opacity:0.6;margin-top:4px) into
a proper .approval-counter rule: 11px, color:var(--muted) (skin-safe vs opacity),
margin:0 0 12px (fixes the 0px gap that left it flush against the buttons),
tabular-nums for stable digits.
- i18n the '1 of N pending' string (was the only hardcoded-English string in an
otherwise fully-localized approval card): new approval_pending_count key in en
+ 14 locales, wired via t() with an English fallback.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Add an opt-in setting to hide Transparent Stream's per-event timestamp chips while keeping the response footer time visible, for users who found the per-event chips noisy. Thanks @rodboev. Closes#6099.
* fix(reasoning): gate reasoning_effort ladder to GLM-5.2+ on native zai
Z.AI's API (docs.z.ai) defines reasoning_effort as GLM-5.2+ exclusive, but
hemes-webui advertised the full 6-level ladder for all 7 GLM models because
_candidate_supports_reasoning has an unconditional glm token match and
_filter_reasoning_efforts_for_provider had no ZAI branch. Six of seven catalog
models (glm-5.1, glm-5, glm-5-turbo, glm-4.7, glm-4.5, glm-4.5-flash) showed a
selector whose values the endpoint silently ignores, and GLM-4.7 (forced thinking
that cannot be disabled per Z.AI docs) showed a 'none' option with no effect.
Add a ZAI branch to _filter_reasoning_efforts_for_provider mirroring the existing
OpenAI/Gemini/Anthropic ceiling pattern: strip the whole ladder for pre-5.2 GLM
models and for the forced-thinking GLM-4.7 family; preserve the full ladder for
GLM-5.2+ (whose accepted values match VALID_REASONING_EFFORTS exactly). The gate
is scoped to the native zai provider only (aliases glm/z-ai/z.ai/zhipu all
resolve to zai); aggregator providers are untouched because they route through
their own routers, not Z.AI's native endpoint.
The glm family-detection heuristic in _candidate_supports_reasoning is unchanged
— GLM models DO support the thinking on/off toggle at the family level; this fix
is specifically about the reasoning_effort intensity ladder.
State layer: agent.reasoning_effort config + UI dropdown options derived from
resolve_model_reasoning_efforts. Invariant: UI options and coercion now agree
and match Z.AI's per-model docs (max offered only for GLM-5.2+, none never
offered for forced-thinking models). Out of scope: the thinking:{type:...}
request-field translation lives in the external agent/gateway layer.
* fix(reasoning): close ZAI coercion gap + harden test assertions
Address Greptile review on #6219 (round 1):
1. Vacuous test assertion (test_glm_5_2_preserves_none_sentinel): the 'or'
fallback made the assertion always-true, so a regression stripping 'none'
for GLM-5.2 would go undetected. Rewrote to inject 'none' via the raw source
(mocking _resolve_model_reasoning_efforts_impl) and assert it survives — the
test now genuinely exercises the preservation branch.
2. Coercion gap for non-max stored levels on pre-5.2 GLM: the existing
'if ceiling and raw not in ceiling' guard treats an empty ceiling as 'no
rule' (preserving the configured effort verbatim per #3505), so a stored
'high'/'medium'/'low' for glm-5.1/glm-4.5/glm-4.7 on native zai was forwarded
to Z.AI unchanged and silently ignored — contradicting the PR's stated
UI/coercion agreement invariant.
Root cause: the ZAI gate returns [] to mean 'known-empty' (no
reasoning_effort at all), but the coercion path treated all [] as
'ambiguous/unknown, preserve verbatim'. Fixed by extracting the ZAI decision
into _zai_glm_reasoning_efforts_supported (True/False/None sentinel) shared by
both the filter and coercion, then special-casing the known-False result in
coerce_reasoning_effort_for_model to return '' (send no field). The #3505
preserve-verbatim behavior for genuinely-unknown models on non-zai providers
is unchanged.
Added 10 regression tests (all fail before the coercion fix, pass after):
- All 6 levels (max..minimal) coerce to '' for each pre-5.2 GLM + glm-4.7
- All 4 aliases (glm/z-ai/z.ai/zhipu) resolve through the same coercion gate
- GLM-5.2 preserves all 6 levels verbatim
- Regression guard: unknown model on custom: provider STILL preserves verbatim
State layer: agent.reasoning_effort config + the value forwarded to Z.AI.
Invariant now fully holds: UI offers no options for pre-5.2 GLM AND coercion
sends no reasoning_effort field for any stored level on those models.
* fix(reasoning): preserve ZAI GLM 4.5-5.1 thinking toggle when effort ladder empty
Address nesquena-hermes round-2 review on #6219: returning [] for the effort
ladder on sub-5.2 GLM models hid the entire reasoning chip in the composer
(static/ui.js:4932 treats empty supported_efforts as 'no reasoning control at
all'), silently regressing the working thinking on/off toggle for GLM-4.5/4.6/
5.0/5.1 users. Per Z.AI's own docs, those models accept the thinking
{type:enabled|disabled} toggle even though they do not accept the
reasoning_effort intensity ladder.
Fix decouples thinking-toggle capability from the effort ladder:
Backend (api/config.py):
- Refactor _zai_glm_classification returns one of 'effort' (GLM-5.2+), 'thinking'
(GLM-4.5 up to but not including 5.2), 'forced' (GLM-4.7 family), or None
(non-zai / non-GLM). Single source of truth shared by all three consumers.
- _zai_glm_reasoning_efforts_supported now wraps classification for the coercion
contract (unchanged behavior).
- _zai_glm_thinking_toggle_supported returns True for 'effort' or 'thinking',
False for 'forced', None otherwise.
- get_reasoning_status gains a supports_thinking_toggle field = bool(supported)
OR (zai_thinking is True). Non-zai providers default to bool(supported_efforts)
so their chip-visibility behavior is unchanged.
Frontend (static/ui.js):
- New _currentReasoningToggleSupported state var (default undefined = treat as
true so legacy responses without the field do not newly hide the chip).
- _applyReasoningChip shows the chip when hasEffortLadder OR toggleSupported.
Empty efforts + toggle=True keeps the chip visible with just the None/On
control (the existing _applyReasoningOptions already shows 'none' when the
ladder is empty). Empty efforts + toggle=False (GLM-4.7 forced) hides it.
- Profile-transition and fetch-failure resets now pass
supports_thinking_toggle:false alongside the empty efforts so the chip hides
during the unknown-state window, matching the prior reset contract.
Tests (30 new):
- _zai_glm_classification parametrized across all three tiers + aliases + defer
- get_reasoning_status supports_thinking_toggle per tier (GLM-5.2 both, GLM-4.6
toggle-only, GLM-4.7 neither, non-zai defaults to effort capability)
- Frontend _applyReasoningChip behavior via node driver: empty efforts +
toggle=True stays visible, toggle=False hides, effort ladder alone is
sufficient, absent field keeps prior behavior
State layer: agent.reasoning_effort config + supports_thinking_toggle field in
/api/reasoning + composer chip visibility. Invariant: the chip is hidden ONLY
when the model supports neither the effort ladder nor the thinking toggle
(GLM-4.7 forced, or genuinely non-reasoning models); GLM-4.5-5.1 retain the
working On/None control they had before the round-1 effort gate.
* fix(reasoning): make ZAI thinking toggle two-way + force GLM-4.7 stored none
Address nesquena-hermes round-3 review on #6219 — two SILENT gaps in the
thinking-toggle path, plus a click-handler sibling I found while auditing.
Gap #1 — ONE-WAY toggle for GLM-4.5/4.6/5.0/5.1 (api/config.py:4247,
static/ui.js:4902, static/index.html:757):
The round-2 fix kept the chip visible for thinking-tier models but the only
rendered dropdown option was 'None' (the HTML had no Default option, and
set_reasoning_effort rejected empty effort with 400). So a GLM-4.6 user could
turn thinking OFF but never back ON — worse than the original bug.
Fix:
- set_reasoning_effort now accepts empty effort as 'clear the override' (removes
agent.reasoning_effort so the provider default takes effect). Invalid values
still raise ValueError.
- static/index.html gains a <div data-effort=''>Default</div> option.
- _applyReasoningOptions always shows both Default ('') and None alongside the
effort ladder, so a thinking-tier model (empty ladder + toggle=true) renders
an operable Default+None two-state control.
- Click handler (ui.js:5106) checks option presence (if(opt)) not truthiness
(if(effort)) — the old check silently ignored data-effort='' clicks, which
would have left the Default button dead even after the HTML/backend changes.
Gap #2 — GLM-4.7 not forced when 'none' stored (api/config.py:3984, :3857):
When GLM-4.7 had agent.reasoning_effort=none configured, coercion preserved
'none' via the early return at line 3985, so streaming built disabled reasoning
for a model that forces thinking on regardless. Separately, when the raw
capability source listed 'none', resolve_model_reasoning_efforts reattached it
to GLM-4.7's supported_efforts (['none']), leaking an 'off' option to the UI
for a forced-thinking model.
Fix:
- coerce_reasoning_effort_for_model checks _zai_glm_classification == 'forced'
BEFORE the generic 'none' early-return, coercing stored 'none' to '' (default
= thinking on) for forced models.
- resolve_model_reasoning_efforts returns [] early for forced-tier models,
skipping the 'none' reattachment entirely.
Tests (17 new):
- Gap #2: coerce('none', glm-4.7) -> '', regression guard that non-forced GLM
still accepts 'none'; resolve does not reattach 'none' for forced but DOES
for thinking-tier; end-to-end get_reasoning_status for forced+stored-none.
- Gap #1 backend: set('') clears the key (no raise), set('garbage') still
raises, all 7 valid levels still save.
- Gap #1 frontend: three new node-driver tests asserting the dropdown exposes
both Default and None for thinking-tier (two-state), Default+None+ladder for
effort-tier, and the off->on->off round trip keeps both visible throughout.
- Updated test_reasoning_show_hide.test_set_reasoning_effort_rejects_invalid to
reflect the new contract (empty accepted, garbage rejected).
Regression gate: gap#2 coerce tests fail without the forced-tier check (3
failures); gap#1 backend test fails with 'ValueError: effort is required'
without the empty-acceptance change. 219 passed, 1 pre-existing skip across
the full reasoning + chip + config-cache surface.
State layer: agent.reasoning_effort config + supports_thinking_toggle field +
composer dropdown options. Invariant: the thinking toggle is now genuinely
two-way for GLM-4.5-5.1 (Default=on, None=off, both always visible) and
GLM-4.7 forced-thinking never offers 'none' in any path (coerce, resolve,
status, or UI).
* CHANGELOG: GLM per-version reasoning controls (#6219)
---------
Co-authored-by: Ruby Hartono <58564005+rh-id@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Two independent defects that chain into "new conversations cannot be started":
every POST /api/session/draft and /api/chat/start 404s a few seconds after
POST /api/session/new returned 200.
1. Search inputs are parsed as username fields.
index.html contains no <form> at all, but does contain password inputs (the
Settings "Access Password" panel). Chromium therefore groups the document's
unowned fields into one synthetic password form and picks the first text input
in the DOM — #sessionSearch, the sidebar conversation filter — as that form's
username field, then autofills the saved account name into it on load.
autocomplete="off" was already present; it is ignored for credential
heuristics.
The autofill fires oninput -> filterSessions() -> GET /api/sessions/search
?content=1&depth=5: a full content search the user never asked for, repeated
on every page load.
Fix: give the four search inputs type="search" plus the per-manager opt-outs
(data-1p-ignore / data-lpignore / data-bwignore / data-form-type), and give the
Settings password inputs a real <form> owner with proper autocomplete tokens so
Chromium scopes the credential form to them instead of to the whole document.
The form uses display:contents, so layout is unchanged. CSS suppresses WebKit's
native search clear button, since these fields ship their own
(#sessionSearchClear).
2. A never-persisted session is treated as evictable.
new_session() intentionally keeps a session in RAM until its first message
(#1171), so the SESSIONS cache is its only copy. _session_is_evictable()
(#4765) short-circuited on zero messages, reasoning that an empty shell "is
recreated on next access". It is not: get_session() has no recreate path and
raises KeyError, so both routes 404 and the session can never be started.
The content search from (1) pulls every hit through get_session(), which blows
past sessions_cache_max (default 300) and evicts the session being composed.
That is why this reproduces within seconds on an install with ~1700 sessions,
and why it grew worse as the session count rose.
Fix: require proof of persistence before evicting, for empty sessions too —
which is what the function's own docstring already promised ("Its full state is
already persisted to the JSON sidecar"). The zero-message branch was the single
path that bypassed it. #4765's memory bound is unaffected: sessions loaded from
disk remain evictable, and its existing tests still pass.
Trade-off: an unsaved empty session now stays resident for the process
lifetime (one per "New Conversation" click, an empty object each). That is a
deliberate application of the function's stated invariant — "slightly more RAM
for a session we are unsure about is strictly better than evicting an unsaved
session". Bounding it by age instead would also work if maintainers prefer.
Validation:
pytest tests/test_issue4765_sessions_lru_eviction.py -> 9 passed
The added regression test fails on the unpatched predicate (verified by
reverting api/models.py alone) and passes with the fix.
Note: (1) fixes what triggers the churn, (2) fixes what turns that churn into
an unstartable session. Either fix alone leaves a real defect in place, so
they are submitted together.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(dictation): preserve composer text when server STT commits
Dictation via the server STT path (MediaRecorder → POST /api/transcribe)
replaced any text already in the composer with the recognized transcript.
Browser SpeechRecognition worked correctly; only the async path was broken.
Root cause: the MediaRecorder.onstop handler calls _setRecording(false)
before awaiting _transcribeBlob's fetch. _setRecording(false) clears the
module-level _prefix. By the time the fetch resolves and _commitTranscript
runs, _prefix is '' — so the existing append logic collapses to
'just the transcript' and overwrites the textarea.
Browser SR dodged this because sr.onend builds the merged string into a
local variable before invoking _setRecording(false). Server STT is async
and could not use that pattern without help.
Fix:
- _commitTranscript accepts an optional prefixOverride parameter.
- _transcribeBlob snapshots _prefix into a local before the await.
- Pass the snapshot through to _commitTranscript.
Add a Settings → Voice → 'Append dictation to composer' toggle (default
ON) so users can opt into replace mode if they prefer the old buggy
behavior. The bug fix changes the default to append, matching what the
browser SR path always did.
i18n: new keys dictation_append_label / dictation_append_desc added to
all 14 locale blocks in static/i18n.js (English fallback where no native
translation exists).
Files: static/boot.js (+43/-7), static/index.html (+7), static/i18n.js (+30).
* fix(dictation): snapshot _prefix in onstop, not inside _transcribeBlob
First attempt at the append-mode fix took the snapshot inside
_transcribeBlob via 'const prefixSnapshot = _prefix'. By the time
_transcribeBlob runs (awaited from recorder.onstop), the sync block
above has already called _setRecording(false), which clears _prefix
inside _setRecording's !on branch. So the snapshot was always '' and
the append path fell through to the replace branch.
Move the snapshot into recorder.onstop, before _setRecording(false)
runs. Pass it as a parameter into _transcribeBlob so the call site
doesn't need to re-read the (now-cleared) module-level _prefix.
No behavior change for the browser-SR path — sr.onend still builds
the merged string into a local variable before invoking
_setRecording(false), and was already correct.
* fix(dictation): trim comments to fit test_590_recording_stops_before_transcribe
test_bugbatch_apr2026.py::test_590_recording_stops_before_transcribe slices
700 chars from 'recorder.onstop' and asserts that _setRecording(false)
comes before _sendRawAudio(/_transcribeBlob( inside that window. With
the verbose 6-line comment block from the previous fix, the actual
call sites landed at offsets 834/890 — past the 700-char window — so
the test's .find() returned -1 and the assertion failed mechanically.
Also: the verbose comment in _transcribeBlob contained the substring
'recorder.onstop', which made the test's first .find() land in the
comment instead of the actual handler, returning a wrong onstop_start.
Trim both comment blocks to single lines that fit within the test's
window and don't contain the literal 'recorder.onstop' substring.
Functional behavior unchanged from the previous fix; this is purely
a comment-shape adjustment to satisfy the brittle test.
* fix(tests): anchor test_590 on handler definition + widen window per review
The reviewer's fix-spec for test_590_recording_stops_before_transcribe
was more thorough than my single-shot comment trim:
(1) Reword the _transcribeBlob comment so it doesn't contain the
literal 'recorder.onstop' substring.
(2) Make the test robust to the handler growing: anchor on
'recorder.onstop=async' instead of 'recorder.onstop', and
widen the fixed 700-char window to 1200.
Applied both. With these changes the test passes regardless of how
much comment text gets added in or around the handler in the future.
Also restored the verbose explanatory comments in both locations
(the previous a7f84027 had trimmed them to fit the brittle 700-char
window — that workaround is no longer needed).
Backward-compat check: the new test logic also passes against
origin/master (verified locally), so this change doesn't regress
the existing green-master invariant.
* fix(dictation): preserve user's mid-transcription edits in async server STT
Greptile flagged this as a 4/5 concern on the append-mode branch:
the async server-STT path appended the dictated text to the stale
snapshot (`_prefix` captured at recording start), which clobbered
any keystrokes the user made during the ~1s transcription round-trip.
Before this fix, with append mode ON:
User typed before mic: 'Hello, '
User typed during STT: 'Hello, from K' (typed while waiting)
Dictated text: 'world'
Result: 'Hello, world' (lost 'from K')
After this fix:
User typed before mic: 'Hello, '
User typed during STT: 'Hello, from K'
Dictated text: 'world'
Result: 'Hello, from K world' (live state preserved)
The fix prefers live `ta.value` as the append base when the textarea
is non-empty, falling back to the `prefixOverride` snapshot only
when the textarea is genuinely empty (the original empty-composer path
where nothing to race with).
Adds a regression test in test_raw_audio_upload.py that asserts the
new `ta.value ||` fallback structure on `_commitTranscript`.
* fix(dictation): don't resurrect cleared text on async transcription resolve
Greptile P1 (re-reviewed at 20:07 UTC): the previous race fix
(`ta.value || (prefixOverride ?? _prefix)`) treats an empty
`ta.value` as falsy and falls back to the recording-start snapshot.
If the user explicitly clears the textarea while server transcription
is in flight, the cleared text reappears with the dictated text
appended — the user's intent is violated.
Fix: when prefixOverride IS passed (server-STT path), trust live
ta.value UNCONDITIONALLY — even when empty. The fallback to _prefix
only fires when no prefixOverride is given AND ta.value is empty,
which is purely defensive (no current caller hits this branch — the
browser-SR path commits inline in sr.onend without calling this
function).
Concrete repro:
User typed before mic: 'Hello, '
User clears textarea during STT
Dictated text: 'world'
OLD (0239bb4e) result: 'Hello, world' (cleared text resurrected)
NEW result: 'world' (user intent respected)
Greptile's suggested one-liner:
const base = prefixOverride !== undefined ? ta.value : (ta.value || _prefix);
Applied with expanded inline comment documenting the three concerns
this function balances (race, clear-during-STT, defensive fallback).
* Release exp-v0.52.49: preserve composer text on server STT commit (#5895)
---------
Co-authored-by: Konstantin M <kopamedve@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Salvage-rebuild of #5473 (@ruizanthony), reworked to OPT-IN per maintainer
decision. New default-OFF setting new_chat_on_workspace_switch: when enabled,
selecting a DIFFERENT workspace starts a fresh chat bound to that workspace
(leaving the current conversation on its original workspace, avoiding stale
cross-workspace context) instead of mutating the current session in place.
Same-workspace selection stays an in-place refresh. Default off preserves the
shipped behavior for everyone.
Wiring: config default + bool key; window._newChatOnWorkspaceSwitch set on
boot + autosave; Settings checkbox (Preferences); gated branch in
switchToWorkspace() that runs before the in-place /api/session/update; 3 i18n
keys across all 15 locales; regression tests (default-off, gated-branch-present,
flag-wiring, checkbox+i18n).
Co-authored-by: ruizanthony <ruizanthony@users.noreply.github.com>
Adversarial security re-gate on the rebased head found 4 issues; fixed:
1. [CORE] api/shares.py public snapshot could leak credentials/paths/tool
payloads. The sanitizer relied on redact_session_data (gated on the
user-toggleable api_redact_enabled) — disabling it exposed API keys, a dict
assistant content was stringified as raw tool data, and session.workspace
survived in transcript text. Now the per-message sanitizer applies ALWAYS-ON
credential redaction (_redact_fn_cached, force=True, ignores the setting) +
scrubs known session/workspace/home paths, and refuses to stringify non-text
structured content. Title is force-redacted too. Regression test proves the
boundary holds with api_redact_enabled=False.
2. [SILENT] api/models.py: my rebase wrongly kept anchor_activity_scenes in
METADATA_FIELDS, moving 250-480KB scene bodies before messages and undoing
master's metadata fast-path (#5854). Removed; 17 #5854 tests pass again.
3. [SILENT] static/index.html: the Share/StopSharing/ExportHTML button triplet
was double-applied by the rebase (duplicate IDs → only the first wired).
Deduped to one each + a count assertion test.
4. tests: fixed the stale Session.load-after-/api/session/new setup (master
keeps new sessions memory-only until first message).
Also (UX, found in the visual flow sweep): a clipboard-copy failure after a
SUCCESSFUL share showed a red 'Share failed' toast and skipped opening the page.
The copy is now isolated — the share succeeds, the page opens, and the toast
shows the link if the clipboard write fails, never a false failure.
Co-authored-by: MicoRobot.
Rebased 363 commits forward (branch-point 2026-07-05). Applied the PR's code
diff cleanly across 16 files; resolved api/models.py additively (both master's
process_wakeup_pause/anchor_scene_index fields AND the PR's share_token/
share_created_at fields kept in the Session constructor, metadata list, and
to_dict). CI workflow (.github/workflows/tests.yml) intentionally NOT taken from
the stale branch — master owns it (the branch's apparent removal of Playwright/
Office steps was a PAT-workflow-scope revert; rebase restores master's CI).
Co-authored-by: MicoRobot. Concept approved by maintainer (opt-in public share,
2026-07-11). Note: 4 dynamic share tests fail identically on the original
unrebased head (pre-existing test-env Session.load fragility, not rebase-caused);
to be resolved during the security re-gate in a proper sandbox.
Salvaged from #4627 by @perejaslav. Core Hermes already supports
reasoning_effort=max; this closes the WebUI parity gap (Anchor-A) — adds
max to VALID_REASONING_EFFORTS + dropdown/chip/reasoning-command, keeps
the degrade ladder for capped models.
Co-authored-by: perejaslav <perejaslav@users.noreply.github.com>
MUST-FIX: /api/updates/check now accepts an explicit 'channel' in the POST body;
the Settings dropdown passes the just-picked value to checkUpdatesNow(channel) so
the immediate re-check can't race the debounced autosave PUT and answer for the
previous channel (verified live: body channel wins over stale saved setting).
SHOULD-FIX:
- copy: 'soaked' -> 'soak-tested'; append 'Applies to WebUI updates only.' to the
channel helper text (all 15 locales, native translations)
- mobile: .settings-version-badge gets max-width/overflow/ellipsis in the <=768px
query so the '· Experimental' suffix can't overflow a 320px viewport
(#5 badge-suffix i18n key deferred — non-blocking fast-follow per Fable, matches
the existing English-only version badge on master.)
Add an update_channel setting (stable|experimental) that selects which git-tag
stream the self-updater tracks on the single linear master line:
stable -> 'v*' promoted, soaked releases (default; unchanged glob)
experimental -> 'exp-v*' every release batch (opt-in testers)
A channel is only a tag glob — no branches, no divergence — so every ff-only
guarantee (#2653/#2846/#3140) is preserved. Channel governs the WebUI repo only;
the Agent repo keeps its historical branch fall-through.
Correctness (advisor-reviewed, Codex + Fable):
- describe uses --match <glob> so a commit tagged both v* and exp-v* resolves
to the channel-correct tag
- stable never falls through to origin/master when HEAD contains the latest
stable tag (the firehose-suppression that makes channels work)
- apply_force_update refuses to reset --hard onto an ancestor ref (rewind guard)
- update cache + in-progress guard keyed by (channel, include_agent)
- channel display badge is a SEPARATE field; WEBUI_VERSION stays channel-neutral
(asset cache-busting / SW cache / stale-client skew all do exact-string equality)
Settings UX: channel dropdown with risk-forward copy + the ff-only asymmetry
helper ('switching back to Stable keeps your current version until Stable catches
up'), channel chip on the version badge, i18n keys, re-check on switch.
Tests: new tests/test_update_channels.py (12 real-git-fixture tests) + existing
update suite updated for the channel-aware signatures.
Address Greptile P1 review (discussion r3530941293) on commit e8f7e28a:
the v2 frontend recovery path was dead at the DOM level. static/ui.js
references $('btnClearUpdateLock') but no matching element existed in
static/index.html, so users hitting a lock conflict never saw a clickable
affordance -- the error panel was the only visible state.
Add the missing button next to btnForceUpdate in the update-banner action
row:
<button class="update-btn" id="btnClearUpdateLock"
style="display:none"
onclick="applyClearUpdateLock(this)">
Clear lock and retry update
</button>
Style mirrors the neutral update-btn (NOT the red --error used by the
destructive Force update) so the recovery affordance doesn't read as a
warning. Hidden by default; revealed by the existing _showUpdateError()
helper when res.lock_conflict is set.
Regression tests in TestClearLockButton (3 tests):
- test_clear_lock_button_exists
- test_clear_lock_button_hidden_by_default
- test_clear_lock_button_calls_applyClearUpdateLock_handler
These lock in the contract that the button element must be present and
correctly bound; future refactors that rename the id or strip the button
will fail loud. Mirrors the existing TestIndexHtmlBanner conventions
for btnForceUpdate.
Verification:
- ./scripts/test.sh tests/test_updates.py -k lock -> 35 passed
- ./scripts/test.sh tests/test_update_banner_fixes.py -> 102 passed
- ./scripts/test.sh tests/test_api_timeout.py -> 8 passed
- ruff_lint --diff upstream/master -> 0 new violations
Re-adds the per-word prose fade for newly-streamed assistant text in Transparent Stream WITHOUT reintroducing the row-level entrance flicker (#5367). Reduced-motion honored at both JS and CSS layers; thinking/tool rows not animated. Option A: the fade is part of Transparent Stream regardless of the off-by-default 'Fade text effect' toggle; settings description clarified accordingly. Gate: Codex SAFE, Fable SHIP-UX, suite 12158/0, browser-smoke clean.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Generalizes renderModelDropdown() to serve both the composer and Preferences pickers (backward-compatible defaults keep the composer identical). Folded in Fable UX fixes: label->chip association, downward shadow for the down-opening settings picker, and mobile keyboard-pop suppression across all 3 render paths (open/internal-filter/refresh) with focus preserved while typing. Full gate (4 rounds): Codex SAFE, Fable SHIP-UX, suite 12153/0, browser clean; composer picker verified unchanged.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Adds 'Copy relative path' to the workspace file-tree context menu + preview header. Folded in gate fixes: preview-header button folds to icon-only on a narrow pane via @container rightpanel (not viewport @media), with data-i18n-title/aria-label so the tooltip+accessible name localize. Full gate: Codex SAFE, Fable SHIP-UX, suite 12147/0, browser clean.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Rebase PR #5162 (rename busy_input_mode -> default_message_mode; flip the
default from 'queue' to 'steer') onto current origin/master WITHOUT dropping
the shipped #5170 localStorage persistence mirror.
Rename the mirror machinery to the new setting name for consistency:
_BUSY_INPUT_MODES -> _DEFAULT_MESSAGE_MODES (values unchanged)
_normalizeBusyInputMode -> _normalizeDefaultMessageMode (fallback now 'steer')
_persistBusyInputMode -> _persistDefaultMessageMode
_readPersistedBusyInputMode -> _readPersistedDefaultMessageMode
window._busyInputMode -> window._defaultMessageMode (+ renamed exports)
localStorage: write the new 'hermes-default-message-mode' key; read it with a
fallback to the legacy 'hermes-busy-input-mode' key so an existing user's
persisted preference survives the rename.
Preserve #5170 behavior at every mirror site under the new names:
- boot success -> window._defaultMessageMode=_persistDefaultMessageMode(...)
- boot FAILURE -> window._defaultMessageMode=_readPersistedDefaultMessageMode()
(NOT a hardcoded 'steer' — a saved 'interrupt'/'queue' must still apply when
the server is unreachable; do not regress #5167/#5132)
- preferences autosave, settings-panel load, and _applySavedSettingsUi all
persist through _persistDefaultMessageMode(...)
Tests updated for the rename while keeping the persistence-behavior assertions
(test_1062, test_5145, test_5167); test_5167 gains explicit guards that the
load-failure path reads the persisted pref and never hardcodes a literal mode,
plus autosave/panel-load mirror-write coverage.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
The #4968 export button was hard-inserted into the composer footer .composer-left
row, bypassing the configurable composer-control framework. On desktop it pushed the
footer over its overflow threshold, tripping _fitComposerFooter into cf-icons mode
which HIDES the model/workspace/profile text labels. Removing it restores label
visibility. Export stays available via the settings-panel HTML button (#btnExportHTML).
Reapply footer control ordering on current upstream/master while preserving the required situational chip renderer. Persist composer_control_order with backend validation, keep the settings descriptions reorder-aware, and make primary/situational chip renderers participate in same-group drag ordering.
Verified with: node --check static/boot.js; node --check static/panels.js; git diff --check; ./scripts/test.sh tests/test_issue4598_composer_control_visibility.py