Release OX (v0.51.437): blur composer on Escape for j/k nav (#3952) (#4258)

* stage-3955: blur composer on Escape for j/k nav + IME guard (Opus nit)

* stage-3955: update test to match IME-guarded blur branch

* Release OX: blur composer on Escape for j/k message nav (#3952)

Self-rebased TomBanksAU's #3955 onto v0.51.436; full Codex+Opus+suite gate.
Escape now blurs the focused composer so j/k session-nav works, after higher-
priority Escape actions run; dropdown-Escape stops propagation; IME-composition
guarded (Opus nit). Also restored the v0.51.434/435 CHANGELOG headings that an
earlier batch left folded under v0.51.436 (hygiene fix).

Co-authored-by: TomBanksAU <TomBanksAU@users.noreply.github.com>

---------

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: TomBanksAU <TomBanksAU@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-06-15 11:55:47 -07:00
committed by GitHub
parent a004e49ebf
commit cdcede8ce7
3 changed files with 91 additions and 1 deletions
+10
View File
@@ -3,16 +3,26 @@
## [Unreleased]
## [v0.51.437] — 2026-06-15 — Release OX (Escape blurs the composer for keyboard nav)
### Fixed
- **Pressing Escape in the message composer now blurs it so j/k message navigation works.** Keyboard-only users could not reach the j/k session-navigation shortcuts while the composer held focus (it swallowed the keys). Escape now blurs the composer when it's focused — after any higher-priority Escape action (closing the slash-command dropdown, dismissing onboarding/settings, cancelling a message edit, clearing the session search) still runs first. The slash-command dropdown's Escape handler now stops propagation so closing it doesn't also blur in the same keystroke, and the blur is skipped while an IME candidate window is composing (so Escape dismisses the candidate for CJK input instead of blurring). (#3952)
## [v0.51.436] — 2026-06-15 — Release OW (cron sessions mark unread on new runs)
### Fixed
- **Cron sessions in the sidebar now mark unread when a new run lands, attributed to the correct job.** When a scheduled job finished, the sidebar's unread resolver matched a session id against `cron_<jobid>_` prefixes built only from the just-completed jobs, so a job whose id is a prefix of another's (e.g. `backup` vs `backup_full`) could steal the other's session (`cron_backup_full_…` resolving to `backup`). The resolver now considers all known job ids and attributes each session to the longest matching job-id prefix, which is provably unambiguous (the matching prefixes of a single session id are nested, so the longest is unique). The lookup is a read-only `state.db` scan that only runs when a cron actually completed, degrades gracefully on contention, and filters in Python rather than via SQL `LIKE`. (#3460)
## [v0.51.435] — 2026-06-15 — Release OV (supported local pytest runner)
### Added
- **`./scripts/test.sh` runs the suite in a repo-local, version-correct virtualenv.** The runner finds a supported interpreter (Python 3.113.13), creates or rebuilds a repo-local `.venv` when needed, installs the pinned dev dependencies from the new `requirements-dev.txt`, and then runs pytest — so contributors get a one-command, correctly-versioned test path instead of a bare `pytest` that may collect against an unsupported system Python. `tests/conftest.py` also fails fast with a clear message if pytest is launched on an unsupported interpreter, and the runner refuses to create or `--clear` a virtualenv through a symlinked `.venv` (which would otherwise empty the symlink's target). Dev-tooling only — no runtime or app behavior changes. (#3908)
## [v0.51.434] — 2026-06-15 — Release OU (reject symlinked skill files on save)
### Fixed
- **Saving a skill no longer writes through a symlinked `SKILL.md`.** `_handle_skill_save` now rejects a symlinked skill file with a 400 instead of following it and overwriting the link's target — extending the workspace symlink hardening (#4217 / #4234) to the skills surface. (#4240)
+7 -1
View File
@@ -1425,7 +1425,7 @@ $('msg').addEventListener('keydown',e=>{
if(e.key==='ArrowUp'){e.preventDefault();navigateCmdDropdown(-1);return;}
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==='Escape'){e.preventDefault();e.stopPropagation();hideCmdDropdown();return;}
if(e.key==='Enter'&&!e.shiftKey){
if(_isImeEnter(e)){return;}
e.preventDefault();
@@ -1521,6 +1521,12 @@ document.addEventListener('keydown',async e=>{
const bar=editArea.closest('.msg-row')&&editArea.closest('.msg-row').querySelector('.msg-edit-bar');
if(bar){const cancel=bar.querySelector('.msg-edit-cancel');if(cancel)cancel.click();}
}
// Blur composer to enable j/k message navigation.
// Skip while an IME candidate window is composing — Escape there should
// dismiss the candidate, not blur the composer (CJK input).
if(document.activeElement===$('msg') && !e.isComposing && !_imeComposing){
$('msg').blur();
}
}
});
$('msg').addEventListener('paste',e=>{
@@ -0,0 +1,74 @@
"""Regression tests for #3952 composer Escape keyboard navigation."""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
def _block_from_opening_brace(src: str, brace: int, label: str) -> str:
assert brace >= 0, f"{label} opening brace must exist"
depth = 1
idx = brace + 1
while idx < len(src) and depth:
if src[idx] == "{":
depth += 1
elif src[idx] == "}":
depth -= 1
idx += 1
assert depth == 0, f"{label} block must close"
return src[brace + 1 : idx - 1]
def _escape_block() -> str:
document_keydown = BOOT_JS.index("// B14: Cmd/Ctrl+K creates a new chat from anywhere")
start = BOOT_JS.index("if(e.key==='Escape'){", document_keydown)
brace = BOOT_JS.index("{", start)
return _block_from_opening_brace(BOOT_JS, brace, "document Escape handler")
def _composer_keydown_block() -> str:
start = BOOT_JS.index("$('msg').addEventListener('keydown',e=>{")
brace = BOOT_JS.index("{", start)
return _block_from_opening_brace(BOOT_JS, brace, "composer keydown handler")
def test_escape_blurs_focused_composer_after_higher_priority_escape_actions():
"""Escape should let keyboard-only users leave the composer and use j/k nav."""
block = _escape_block()
blur_idx = block.find("document.activeElement===$('msg')")
assert blur_idx != -1, "Escape handler must detect the focused composer"
assert "$('msg').blur();" in block[blur_idx:], "Escape handler must blur the composer"
# The blur must be skipped while an IME candidate window is composing, so
# Escape dismisses the candidate (CJK input) rather than blurring the composer.
blur_line = block[blur_idx : block.find("\n", blur_idx)]
assert "!e.isComposing" in blur_line and "!_imeComposing" in blur_line, (
"Escape blur must be guarded against IME composition"
)
assert block.index("skipOnboarding") < blur_idx, "onboarding dismissal stays higher priority"
assert block.index("_closeSettingsPanel") < blur_idx, "settings dismissal stays higher priority"
assert block.index("clearSessionSearch") < blur_idx, "session-search clearing stays higher priority"
assert block.index("msg-edit-cancel") < blur_idx, "message-edit cancel stays higher priority"
def test_jk_session_navigation_still_ignores_interactive_targets():
"""Composer text entry still owns j/k until Escape blurs it."""
nav_start = SESSIONS_JS.index("// Keyboard session navigation — J/K bindings")
nav_block = SESSIONS_JS[nav_start:]
assert "if(typeof _isInteractiveSwipeTarget==='function'&&_isInteractiveSwipeTarget(e.target)) return;" in nav_block
def test_escape_dismisses_command_dropdown_without_blurring_composer():
"""Escape should close slash-command autocomplete without bubbling to blur."""
block = _composer_keydown_block()
escape_idx = block.find("if(e.key==='Escape'){")
assert escape_idx != -1, "composer keydown handler must handle dropdown Escape"
escape_line = block[escape_idx : block.find("}", escape_idx) + 1]
assert "e.preventDefault();" in escape_line
assert "e.stopPropagation();" in escape_line
assert "hideCmdDropdown();" in escape_line