feat(#3845): add J/K keyboard shortcuts for previous/next session navigation

Global j/k keydown bindings navigate the session list (j=next, k=prev), guarded
by _isInteractiveSwipeTarget so they never fire while typing in the composer or
any input/textarea/contenteditable. Modifier-key combos are ignored.

Closes #3845.

Co-authored-by: Rod Boev <rod.boev@gmail.com>
This commit is contained in:
nesquena-hermes
2026-06-10 19:49:14 +00:00
parent 6988995389
commit 337e3b50ab
2 changed files with 118 additions and 0 deletions

View File

@@ -6169,3 +6169,22 @@ async function _confirmDeleteProject(proj){
document.addEventListener('keydown',(e)=>{
if(e.key==='Escape'&&_sessionSelectMode) exitSessionSelectMode();
});
// Keyboard session navigation — J/K bindings
function navigateSession(dir){
const rows=[...document.querySelectorAll('.session-item[data-sid]')];
const sids=rows.map(r=>r.dataset.sid);
const cur=S.session&&S.session.session_id;
const i=sids.indexOf(cur);
if(i<0||!sids.length)return;
const next=sids[Math.min(Math.max(i+dir,0),sids.length-1)];
if(next&&next!==cur) loadSession(next);
}
document.addEventListener('keydown',(e)=>{
if(e.key!=='j'&&e.key!=='k') return;
if(e.ctrlKey||e.metaKey||e.altKey) return;
if(typeof _isInteractiveSwipeTarget==='function'&&_isInteractiveSwipeTarget(e.target)) return;
e.preventDefault();
navigateSession(e.key==='j'?1:-1);
});

View File

@@ -0,0 +1,99 @@
"""Regression check for #3845 — keyboard session navigation with J/K bindings.
The session list supports keyboard navigation using J (next) and K (previous)
to move focus through sessions without mouse interaction. Verified at the source
level so this stays fast.
"""
from pathlib import Path
REPO = Path(__file__).parent.parent
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
def test_navigateSession_function_exists():
"""navigateSession function is defined and exported to global scope."""
assert "function navigateSession(dir)" in SESSIONS_JS
def test_navigateSession_calls_loadSession():
"""navigateSession calls loadSession(next) when switching sessions."""
# Extract the navigateSession function definition
start = SESSIONS_JS.index("function navigateSession(dir)")
end = SESSIONS_JS.index("}", start) + 1
func_body = SESSIONS_JS[start:end]
assert "loadSession(next)" in func_body
def test_navigateSession_queries_session_items():
"""navigateSession queries .session-item[data-sid] elements."""
start = SESSIONS_JS.index("function navigateSession(dir)")
end = SESSIONS_JS.index("}", start) + 1
func_body = SESSIONS_JS[start:end]
assert ".session-item[data-sid]" in func_body
def test_j_k_keydown_listener_exists():
"""J/K keyboard listener is registered on document."""
# Find the keyboard listener that checks for j and k keys
assert "if(e.key!=='j'&&e.key!=='k')" in SESSIONS_JS
def test_j_k_listener_appears_after_navigateSession():
"""J/K listener appears after navigateSession function definition."""
nav_start = SESSIONS_JS.index("function navigateSession(dir)")
jk_start = SESSIONS_JS.index("if(e.key!=='j'&&e.key!=='k')", nav_start)
assert jk_start > nav_start
def test_j_k_listener_prevents_default():
"""e.preventDefault() is called before navigateSession in J/K listener."""
jk_start = SESSIONS_JS.index("if(e.key!=='j'&&e.key!=='k')")
# Extract the J/K listener code block
jk_block_end = SESSIONS_JS.index("});", jk_start)
jk_block = SESSIONS_JS[jk_start:jk_block_end]
assert "e.preventDefault()" in jk_block
# Verify preventDefault comes before navigateSession call
prevent_idx = jk_block.index("e.preventDefault()")
nav_idx = jk_block.index("navigateSession")
assert prevent_idx < nav_idx
def test_j_k_listener_checks_modifiers():
"""J/K listener ignores when Ctrl, Meta, or Alt modifiers are active."""
jk_start = SESSIONS_JS.index("if(e.key!=='j'&&e.key!=='k')")
jk_block_end = SESSIONS_JS.index("});", jk_start)
jk_block = SESSIONS_JS[jk_start:jk_block_end]
assert "e.ctrlKey" in jk_block
assert "e.metaKey" in jk_block
assert "e.altKey" in jk_block
def test_j_k_listener_checks_interactive_swipe_target():
"""J/K listener checks _isInteractiveSwipeTarget to avoid input fields."""
jk_start = SESSIONS_JS.index("if(e.key!=='j'&&e.key!=='k')")
jk_block_end = SESSIONS_JS.index("});", jk_start)
jk_block = SESSIONS_JS[jk_start:jk_block_end]
assert "_isInteractiveSwipeTarget" in jk_block
assert "typeof _isInteractiveSwipeTarget===" in jk_block
def test_j_navigates_forward():
"""Pressing J calls navigateSession with dir=1 (forward)."""
jk_start = SESSIONS_JS.index("if(e.key!=='j'&&e.key!=='k')")
jk_block_end = SESSIONS_JS.index("});", jk_start)
jk_block = SESSIONS_JS[jk_start:jk_block_end]
assert "navigateSession(e.key==='j'?1:-1)" in jk_block
def test_k_navigates_backward():
"""Pressing K calls navigateSession with dir=-1 (backward)."""
jk_start = SESSIONS_JS.index("if(e.key!=='j'&&e.key!=='k')")
jk_block_end = SESSIONS_JS.index("});", jk_start)
jk_block = SESSIONS_JS[jk_start:jk_block_end]
# The ternary "e.key==='j'?1:-1" encodes both J and K behavior
assert "navigateSession(e.key==='j'?1:-1)" in jk_block