* 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>
195 lines
9.4 KiB
Python
195 lines
9.4 KiB
Python
"""
|
|
Bug batch fixes — April 2026.
|
|
|
|
Covers:
|
|
- #594: .app-dialog and .file-rename-input have light theme overrides in style.css
|
|
- #576: workspace panel localStorage restore is gated on session.workspace presence (boot.js)
|
|
- #585: get_available_models() calls reload_config() before reading config cache
|
|
- #567: docker-compose.yml comment mentions macOS UID mismatch
|
|
- #590: _transcribeBlob already calls setComposerStatus('Transcribing…') — confirmed present
|
|
"""
|
|
import pathlib
|
|
import re
|
|
|
|
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
|
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
|
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
|
COMPOSE = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
|
|
|
|
|
# ── #594: light theme dialog overrides ───────────────────────────────────────
|
|
|
|
def test_594_app_dialog_has_light_mode_override():
|
|
"""style.css must have a light mode rule targeting .app-dialog background."""
|
|
assert ':root:not(.dark) .app-dialog{' in STYLE_CSS, (
|
|
"Missing light mode override for .app-dialog — dialogs appear dark on light theme"
|
|
)
|
|
|
|
|
|
def test_594_app_dialog_input_has_light_mode_override():
|
|
"""style.css must have a light mode rule for .app-dialog-input."""
|
|
assert ":root:not(.dark) .app-dialog-input{" in STYLE_CSS, (
|
|
"Missing light mode override for .app-dialog-input"
|
|
)
|
|
|
|
|
|
def test_594_app_dialog_btn_has_light_mode_override():
|
|
"""style.css must have a light mode rule for .app-dialog-btn."""
|
|
assert ":root:not(.dark) .app-dialog-btn{" in STYLE_CSS, (
|
|
"Missing light mode override for .app-dialog-btn"
|
|
)
|
|
|
|
|
|
def test_594_app_dialog_close_has_light_mode_override():
|
|
"""style.css must have a light mode rule for .app-dialog-close."""
|
|
assert ":root:not(.dark) .app-dialog-close{" in STYLE_CSS, (
|
|
"Missing light mode override for .app-dialog-close"
|
|
)
|
|
|
|
|
|
def test_594_file_rename_input_has_light_mode_override():
|
|
"""style.css must have a light mode rule for .file-rename-input."""
|
|
assert ":root:not(.dark) .file-rename-input{" in STYLE_CSS, (
|
|
"Missing light mode override for .file-rename-input"
|
|
)
|
|
|
|
|
|
# ── dark-mode user bubble semantics ──────────────────────────────────────────
|
|
|
|
def test_dark_user_bubbles_use_dark_tinted_surface():
|
|
"""Dark mode should keep user bubbles dark, with skin only tinting the bubble."""
|
|
assert "--user-bubble-bg: var(--accent-bg-strong);" in STYLE_CSS, (
|
|
"Dark mode user bubbles should use the dark accent tint, not the full bright accent fill"
|
|
)
|
|
assert "--user-bubble-border: var(--accent-bg-strong);" in STYLE_CSS, (
|
|
"Dark mode user bubble borders should match the quieter thinking-card border intensity"
|
|
)
|
|
assert "--user-bubble-text: var(--text);" in STYLE_CSS, (
|
|
"Dark mode user bubble text should inherit the theme text color"
|
|
)
|
|
|
|
|
|
def test_dark_user_bubbles_do_not_need_per_skin_text_hacks():
|
|
"""Dark-mode user bubble contrast should not rely on per-skin text overrides."""
|
|
assert re.search(r':root\.dark\[data-skin="[^"]+"\]\s*\{\s*--user-bubble-text:', STYLE_CSS) is None, (
|
|
"Dark-mode user bubble contrast should come from shared theme tokens, not per-skin text hacks"
|
|
)
|
|
|
|
|
|
def test_user_bubbles_define_selection_tokens_for_both_modes():
|
|
"""User bubbles need dedicated selection colors so selected text remains readable."""
|
|
assert "--user-selection-bg: rgba(0,0,0,.22);" in STYLE_CSS, (
|
|
"Light-mode user bubbles should define a darker selection fill for contrast"
|
|
)
|
|
assert "--user-selection-bg: rgba(255,255,255,.18);" in STYLE_CSS, (
|
|
"Dark-mode user bubbles should define a lighter selection fill for contrast"
|
|
)
|
|
assert "--user-selection-text: #fff;" in STYLE_CSS, (
|
|
"Light-mode user bubble selection should preserve readable text color"
|
|
)
|
|
|
|
|
|
def test_user_bubble_selection_is_scoped_to_user_message_body():
|
|
"""Selection override must apply only to user bubbles, including nested markdown nodes."""
|
|
assert '.msg-row[data-role="user"] .msg-body::selection,' in STYLE_CSS, (
|
|
"Missing selection override on the user message bubble"
|
|
)
|
|
assert '.msg-row[data-role="user"] .msg-body *::selection {' in STYLE_CSS, (
|
|
"Nested elements inside user messages must inherit the same selection colors"
|
|
)
|
|
|
|
|
|
# ── #576: workspace panel snap fix ───────────────────────────────────────────
|
|
|
|
def test_576_panel_restore_gated_on_workspace():
|
|
"""boot.js: localStorage panel restore must be gated on session.workspace."""
|
|
# The guard must appear: session.workspace check before _workspacePanelMode='browse'
|
|
# Panel pref key takes priority over runtime key (toolbar close must not clear preference)
|
|
assert "S.session&&S.session.workspace&&panelPref" in BOOT_JS, (
|
|
"Workspace panel localStorage restore must be gated on S.session.workspace "
|
|
"to prevent snap-open-then-closed on sessions without a workspace (#576)"
|
|
)
|
|
assert "'hermes-webui-workspace-panel-pref'" in BOOT_JS, (
|
|
"Panel restore must check the preference key so toolbar close does not clear it"
|
|
)
|
|
|
|
|
|
def test_576_restore_happens_after_load_session():
|
|
"""boot.js: loadSession() must come before the panel restore guard."""
|
|
load_pos = BOOT_JS.find("await loadSession(saved, {preserveActiveInput:true})")
|
|
restore_pos = BOOT_JS.find("panelPref")
|
|
assert load_pos != -1, "loadSession call not found in boot.js"
|
|
assert restore_pos != -1, "workspace panel restore guard not found"
|
|
assert load_pos < restore_pos, (
|
|
"loadSession() must run before the panel restore guard "
|
|
"so S.session.workspace is known at restore time"
|
|
)
|
|
|
|
|
|
# ── #585: get_available_models reloads config ─────────────────────────────────
|
|
|
|
def test_585_get_available_models_calls_reload_config():
|
|
"""api/config.py: get_available_models() must do a mtime-based reload check."""
|
|
config_src = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
|
fn_start = config_src.find("def get_available_models(")
|
|
assert fn_start != -1, "get_available_models not found"
|
|
fn_body_end = config_src.find('"""', config_src.find('"""', fn_start + 30) + 3) + 3
|
|
# Must check mtime before reading config
|
|
mtime_pos = config_src.find("_current_mtime", fn_body_end)
|
|
active_prov_pos = config_src.find("active_provider = None", fn_body_end)
|
|
assert mtime_pos != -1, (
|
|
"get_available_models() must check config file mtime before reading cache (#585)"
|
|
)
|
|
assert mtime_pos < active_prov_pos, (
|
|
"mtime check must come before active_provider = None in get_available_models()"
|
|
)
|
|
|
|
|
|
# ── #567: docker-compose UID note ─────────────────────────────────────────────
|
|
|
|
def test_567_compose_mentions_macos_uid():
|
|
"""docker-compose.yml must mention macOS UID / id -u to help macOS users."""
|
|
assert "macOS" in COMPOSE or "macos" in COMPOSE.lower(), (
|
|
"docker-compose.yml should mention macOS UID issue (#567)"
|
|
)
|
|
assert "id -u" in COMPOSE, (
|
|
"docker-compose.yml should tell users to run 'id -u' to find their UID (#567)"
|
|
)
|
|
|
|
|
|
# ── #590: transcription spinner already present ───────────────────────────────
|
|
|
|
def test_590_transcribing_status_shown_before_fetch():
|
|
"""boot.js: setComposerStatus('Transcribing…') must fire before the fetch call."""
|
|
transcribe_fn_start = BOOT_JS.find("async function _transcribeBlob(")
|
|
assert transcribe_fn_start != -1, "_transcribeBlob not found in boot.js"
|
|
fn_body = BOOT_JS[transcribe_fn_start:transcribe_fn_start + 600]
|
|
status_pos = fn_body.find("setComposerStatus('Transcribing")
|
|
fetch_pos = fn_body.find("await fetch(")
|
|
assert status_pos != -1, (
|
|
"setComposerStatus('Transcribing…') must be called before the fetch in _transcribeBlob"
|
|
)
|
|
assert fetch_pos != -1, "await fetch not found in _transcribeBlob"
|
|
assert status_pos < fetch_pos, (
|
|
"setComposerStatus('Transcribing…') must appear before 'await fetch' "
|
|
"so the UI shows a spinner immediately on stop (#590)"
|
|
)
|
|
|
|
|
|
def test_590_recording_stops_before_transcribe():
|
|
"""boot.js: _setRecording(false) must fire in onstop before the pinned send path."""
|
|
# Anchor on the handler definition (not just the substring "recorder.onstop",
|
|
# which can collide with comments referencing the handler) and widen the
|
|
# window so future comment additions don't push the send path out of range.
|
|
onstop_start = BOOT_JS.find("recorder.onstop=async")
|
|
assert onstop_start != -1, "recorder.onstop=async handler not found"
|
|
onstop_body = BOOT_JS[onstop_start:onstop_start + 1200]
|
|
rec_pos = onstop_body.find("_setRecording(false)")
|
|
blob_pos = onstop_body.find("_transcribeBlob(")
|
|
raw_pos = onstop_body.find("_sendRawAudio(")
|
|
send_pos = raw_pos if raw_pos != -1 and (blob_pos == -1 or raw_pos < blob_pos) else blob_pos
|
|
assert rec_pos != -1 and send_pos != -1
|
|
assert rec_pos < send_pos, (
|
|
"_setRecording(false) must come before the pinned send path so the mic icon clears immediately"
|
|
)
|