diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ae400f..63b157d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Hermes Web UI -- Changelog
+## [v0.50.49] — 2026-04-15
+
+### Fixed
+- **IME composition** — `isComposing` guard added to every Enter keydown handler so CJK/Japanese/Korean input method users never accidentally send mid-composition (fixes #531). Covers chat composer, command dropdown, session rename, project create/rename, app dialog, message edit, and workspace rename. Adds 3 regression tests. (PR #537 by @vansour)
+
## [v0.50.48] fix: toast when model is switched during active session (#419)
Synthesized from PRs #516 (armorbreak001), #517 and #518 (cloudyun888).
diff --git a/static/boot.js b/static/boot.js
index 771cd47..75f322b 100644
--- a/static/boot.js
+++ b/static/boot.js
@@ -446,7 +446,12 @@ $('msg').addEventListener('keydown',e=>{
if(e.key==='ArrowDown'){e.preventDefault();navigateCmdDropdown(1);return;}
if(e.key==='Tab'){e.preventDefault();selectCmdDropdownItem();return;}
if(e.key==='Escape'){e.preventDefault();hideCmdDropdown();return;}
- if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();selectCmdDropdownItem();return;}
+ if(e.key==='Enter'&&!e.shiftKey){
+ if(e.isComposing){return;}
+ e.preventDefault();
+ selectCmdDropdownItem();
+ return;
+ }
}
// Send key: respect user preference.
// On touch-primary devices (software keyboard), default to Enter = newline
@@ -454,6 +459,7 @@ $('msg').addEventListener('keydown',e=>{
// The 'ctrl+enter' setting also uses this behavior (Enter = newline).
// Users can override in Settings by explicitly choosing 'enter' mode.
if(e.key==='Enter'){
+ if(e.isComposing){return;}
const _mobileDefault=matchMedia('(pointer:coarse)').matches&&window._sendKey==='enter';
if(window._sendKey==='ctrl+enter'||_mobileDefault){
if(e.ctrlKey||e.metaKey){e.preventDefault();send();}
diff --git a/static/index.html b/static/index.html
index a1dd8cf..5c20188 100644
--- a/static/index.html
+++ b/static/index.html
@@ -552,7 +552,7 @@
System
Instance version and access controls.
- v0.50.48
+ v0.50.49
diff --git a/static/sessions.js b/static/sessions.js
index f24ce8d..80aaaa2 100644
--- a/static/sessions.js
+++ b/static/sessions.js
@@ -671,7 +671,12 @@ function renderSessionListFromCache(){
setTimeout(()=>{ if(_renamingSid===null) renderSessionListFromCache(); },50);
};
inp.onkeydown=e2=>{
- if(e2.key==='Enter'){e2.preventDefault();e2.stopPropagation();finish(true);}
+ if(e2.key==='Enter'){
+ if(e2.isComposing){return;}
+ e2.preventDefault();
+ e2.stopPropagation();
+ finish(true);
+ }
if(e2.key==='Escape'){e2.preventDefault();e2.stopPropagation();finish(false);}
};
// onblur: cancel only -- no accidental saves
@@ -888,7 +893,11 @@ function _startProjectCreate(bar, addBtn){
}
};
inp.onkeydown=(e)=>{
- if(e.key==='Enter'){e.preventDefault();finish(true);}
+ if(e.key==='Enter'){
+ if(e.isComposing){return;}
+ e.preventDefault();
+ finish(true);
+ }
if(e.key==='Escape'){e.preventDefault();finish(false);}
};
inp.onblur=()=>finish(false);
@@ -910,7 +919,11 @@ function _startProjectRename(proj, chip){
}
};
inp.onkeydown=(e)=>{
- if(e.key==='Enter'){e.preventDefault();finish(true);}
+ if(e.key==='Enter'){
+ if(e.isComposing){return;}
+ e.preventDefault();
+ finish(true);
+ }
if(e.key==='Escape'){e.preventDefault();finish(false);}
};
inp.onblur=()=>finish(false);
diff --git a/static/ui.js b/static/ui.js
index 3fb2601..e6342df 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -767,6 +767,7 @@ function _ensureAppDialogBindings(){
return;
}
if(e.key==='Enter'){
+ if(e.isComposing) return;
const target=e.target;
const isTextarea=target&&target.tagName==='TEXTAREA';
if(!isTextarea){
@@ -1399,7 +1400,7 @@ function editMessage(btn) {
bar.querySelector('.msg-edit-cancel').onclick = () => cancelEdit(row, originalText, body);
ta.addEventListener('keydown', e => {
- if(e.key==='Enter' && !e.shiftKey) { e.preventDefault(); bar.querySelector('.msg-edit-send').click(); }
+ if(e.key==='Enter' && !e.shiftKey) { if(e.isComposing) return; e.preventDefault(); bar.querySelector('.msg-edit-send').click(); }
if(e.key==='Escape') { e.preventDefault(); cancelEdit(row, originalText, body); }
});
}
@@ -1719,7 +1720,11 @@ function _renderTreeItems(container, entries, depth){
inp.replaceWith(nameEl);
};
inp.onkeydown=(e2)=>{
- if(e2.key==='Enter'){e2.preventDefault();finish(true);}
+ if(e2.key==='Enter'){
+ if(e2.isComposing){return;}
+ e2.preventDefault();
+ finish(true);
+ }
if(e2.key==='Escape'){e2.preventDefault();finish(false);}
};
inp.onblur=()=>finish(false);
diff --git a/tests/test_ime_composition.py b/tests/test_ime_composition.py
new file mode 100644
index 0000000..7471689
--- /dev/null
+++ b/tests/test_ime_composition.py
@@ -0,0 +1,61 @@
+import pathlib
+import re
+
+
+REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
+BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
+UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
+SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
+
+
+def _ime_guarded_enter_pattern(event_var_pattern, require_no_shift=False):
+ no_shift = rf"\s*&&\s*!\s*{event_var_pattern}\.shiftKey" if require_no_shift else ""
+ return (
+ rf"if\s*\(\s*{event_var_pattern}\.key\s*===\s*'Enter'{no_shift}\s*\)\s*\{{\s*"
+ rf"if\s*\(\s*{event_var_pattern}\.isComposing\s*\)\s*"
+ rf"(?:\{{\s*return\s*;?\s*\}}|return\s*;?)"
+ )
+
+
+def test_boot_chat_enter_send_respects_ime_composition():
+ assert re.search(
+ _ime_guarded_enter_pattern("e"),
+ BOOT_JS,
+ re.DOTALL,
+ ), "Chat composer Enter handler must ignore IME composition Enter in static/boot.js"
+ assert re.search(
+ _ime_guarded_enter_pattern("e", require_no_shift=True),
+ BOOT_JS,
+ re.DOTALL,
+ ), "Command dropdown Enter handler must ignore IME composition Enter in static/boot.js"
+
+
+def test_ui_enter_submit_paths_respect_ime_composition():
+ assert re.search(
+ rf"document\.addEventListener\('keydown',e=>\{{[\s\S]*?{_ime_guarded_enter_pattern('e')}",
+ UI_JS,
+ re.DOTALL,
+ ), \
+ "App dialog Enter handler must ignore IME composition Enter in static/ui.js"
+ assert re.search(
+ _ime_guarded_enter_pattern("e", require_no_shift=True),
+ UI_JS,
+ re.DOTALL,
+ ), \
+ "Message edit Enter-to-save handler must ignore IME composition Enter in static/ui.js"
+ assert re.search(
+ rf"inp\.onkeydown=\(e2\)=>\{{\s*{_ime_guarded_enter_pattern('e2')}",
+ UI_JS,
+ re.DOTALL,
+ ), \
+ "Workspace rename Enter handler must ignore IME composition Enter in static/ui.js"
+
+
+def test_sessions_enter_submit_paths_respect_ime_composition():
+ matches = re.findall(
+ _ime_guarded_enter_pattern(r"e2?"),
+ SESSIONS_JS,
+ re.DOTALL,
+ )
+ assert len(matches) >= 3, \
+ "Session and project rename/create Enter handlers must ignore IME composition Enter in static/sessions.js"