Improve conversation actions menu keyboard access

This commit is contained in:
sheldon-im authored and nesquena-hermes committed 2026-07-13 19:24:09 +00:00
1 parent 41f1eb5624
commit bc85711af2
3 files changed
+205 -3

No files matched your search

+44 -3
View File
@@ -3777,6 +3777,7 @@ _restoreSessionSourceFilter();
let _sessionActionMenu = null;
let _sessionActionAnchor = null;
let _sessionActionSessionId = null;
let _sessionActionMenuId = 0;
const _expandedChildSessionKeys = new Set();
const _expandedLineageKeys = new Set();
const _lineageReportCache = new Map();
@@ -4175,7 +4176,8 @@ function _showBatchProjectPicker(){
setTimeout(()=>document.addEventListener('click',close),0);
}
function closeSessionActionMenu(){
function closeSessionActionMenu({restoreFocus=false}={}){
const focusTarget=restoreFocus?_sessionActionAnchor:null;
if(_sessionActionMenu){
_sessionActionMenu.remove();
_sessionActionMenu = null;
@@ -4183,12 +4185,17 @@ function closeSessionActionMenu(){
if(_sessionActionAnchor){
if(_sessionActionAnchor.classList&&_sessionActionAnchor.classList.contains('session-actions-trigger')){
_sessionActionAnchor.classList.remove('active');
_sessionActionAnchor.setAttribute('aria-expanded','false');
_sessionActionAnchor.removeAttribute('aria-controls');
}
const row=_sessionActionAnchor.closest('.session-item,.session-child-session');
if(row) row.classList.remove('menu-open','long-pressing');
_sessionActionAnchor = null;
}
_sessionActionSessionId = null;
if(focusTarget&&focusTarget.isConnected&&typeof focusTarget.focus==='function'){
try{focusTarget.focus({preventScroll:true});}catch(_){focusTarget.focus();}
}
}
function _sessionActionMenuShouldIgnoreScrollTarget(target){
@@ -4240,6 +4247,7 @@ function _buildSessionAction(label, meta, icon, onSelect, extraClass=''){
const opt=document.createElement('button');
opt.type='button';
opt.className='ws-opt session-action-opt'+(extraClass?` ${extraClass}`:'');
opt.setAttribute('role','menuitem');
// Compact context-menu shape (#3223 redesign, Nathan 2026-06-01): show only
// icon + label, matching VS Code / browser / ChatGPT conversation menus. The
// descriptive `meta` is preserved as a hover tooltip (title=) so the
@@ -4313,11 +4321,39 @@ function _mountSessionActionMenu(menu, session, anchorEl){
_sessionActionMenu = menu;
_sessionActionAnchor = anchorEl;
_sessionActionSessionId = session.session_id;
if(anchorEl.classList&&anchorEl.classList.contains('session-actions-trigger')) anchorEl.classList.add('active');
if(anchorEl.classList&&anchorEl.classList.contains('session-actions-trigger')){
anchorEl.classList.add('active');
anchorEl.setAttribute('aria-expanded','true');
anchorEl.setAttribute('aria-controls',menu.id);
}
const row=anchorEl.closest('.session-item,.session-child-session');
if(row) row.classList.add('menu-open');
_positionSessionActionMenu(anchorEl);
_playSessionActionMenuEntrance(menu);
const menuItems=()=>Array.from(menu.querySelectorAll('.session-action-opt:not([disabled])'));
menu.addEventListener('keydown',e=>{
const items=menuItems();
if(e.key==='Escape'){
e.preventDefault();
e.stopPropagation();
closeSessionActionMenu({restoreFocus:true});
return;
}
if(!items.length) return;
const currentIndex=Math.max(0,items.indexOf(document.activeElement));
let nextIndex=null;
if(e.key==='ArrowDown') nextIndex=(currentIndex+1)%items.length;
else if(e.key==='ArrowUp') nextIndex=(currentIndex-1+items.length)%items.length;
else if(e.key==='Home') nextIndex=0;
else if(e.key==='End') nextIndex=items.length-1;
if(nextIndex===null) return;
e.preventDefault();
try{items[nextIndex].focus({preventScroll:true});}catch(_){items[nextIndex].focus();}
});
const firstAction=menuItems()[0];
if(firstAction){
try{firstAction.focus({preventScroll:true});}catch(_){firstAction.focus();}
}
}
function _findSessionRenameRow(sessionId){
@@ -4622,6 +4658,9 @@ function _openSessionActionMenu(session, anchorEl){
const isExternalSession = isMessagingSession || isCliSession;
const menu=document.createElement('div');
menu.className='session-action-menu';
menu.id='sessionActionMenu-'+(++_sessionActionMenuId);
menu.setAttribute('role','menu');
menu.setAttribute('aria-label', 'Conversation actions');
_appendSessionCopyLinkAction(menu, session);
if(isReadOnly){
_appendSessionExportHtmlAction(menu, session);
@@ -4812,7 +4851,7 @@ document.addEventListener('scroll',e=>{
closeSessionActionMenu();
}, true);
document.addEventListener('keydown',e=>{
if(e.key==='Escape' && _sessionActionMenu) closeSessionActionMenu();
if(e.key==='Escape' && _sessionActionMenu) closeSessionActionMenu({restoreFocus:true});
});
window.addEventListener('resize',()=>{
if(_sessionActionMenu && _sessionActionAnchor) _positionSessionActionMenu(_sessionActionAnchor);
@@ -8250,6 +8289,7 @@ function renderSessionListFromCache(){
menuBtn.className='session-actions-trigger';
menuBtn.title='Conversation actions';
menuBtn.setAttribute('aria-haspopup','menu');
menuBtn.setAttribute('aria-expanded','false');
menuBtn.setAttribute('aria-label','Conversation actions');
menuBtn.innerHTML=ICONS.more;
const stopMenuPointer=(e)=>e.stopPropagation();
@@ -8348,6 +8388,7 @@ function renderSessionListFromCache(){
menuBtn.className='session-actions-trigger';
menuBtn.title='Conversation actions';
menuBtn.setAttribute('aria-haspopup','menu');
menuBtn.setAttribute('aria-expanded','false');
menuBtn.setAttribute('aria-label','Conversation actions');
menuBtn.innerHTML=ICONS.more;
const stopMenuPointer=(e)=>e.stopPropagation();
+126
View File
@@ -0,0 +1,126 @@
"""Browser regression coverage for the portaled conversation-actions menu."""
from pathlib import Path
import re
import pytest
SESSIONS_JS = (Path(__file__).resolve().parents[1] / "static" / "sessions.js").read_text(
encoding="utf-8"
)
def _function_source(name: str) -> str:
marker = f"function {name}"
start = SESSIONS_JS.find(marker)
assert start >= 0, f"{name} not found"
signature_end = re.search(r"\)\s*\{", SESSIONS_JS[start:])
assert signature_end, f"{name} signature did not close"
brace = start + signature_end.end() - 1
depth = 1
index = brace + 1
while depth and index < len(SESSIONS_JS):
if SESSIONS_JS[index] == "{":
depth += 1
elif SESSIONS_JS[index] == "}":
depth -= 1
index += 1
assert depth == 0, f"{name} body did not close"
return SESSIONS_JS[start:index]
def _fixture_script() -> str:
"""Run the production menu lifecycle in a small real-DOM fixture.
The menu is deliberately portaled to body in production. This fixture stubs
only positioning/animation helpers, so the browser verifies the real focus,
ARIA, and keyboard lifecycle without needing a live agent session.
"""
return "\n".join(
[
"let _sessionActionMenu = null;",
"let _sessionActionAnchor = null;",
"let _sessionActionSessionId = null;",
"const esc = value => String(value);",
"function _positionSessionActionMenu(){}",
"function _playSessionActionMenuEntrance(){}",
_function_source("closeSessionActionMenu"),
_function_source("_buildSessionAction"),
_function_source("_mountSessionActionMenu"),
"""
window.__sessionActionMenuFocusResult = () => {
const row = document.createElement('div');
row.className = 'session-item';
const trigger = document.createElement('button');
trigger.className = 'session-actions-trigger';
trigger.setAttribute('aria-haspopup', 'menu');
trigger.setAttribute('aria-expanded', 'false');
trigger.setAttribute('aria-label', 'Conversation actions');
row.appendChild(trigger);
document.body.appendChild(row);
trigger.focus();
const menu = document.createElement('div');
menu.className = 'session-action-menu';
menu.id = 'sessionActionMenu-browser-test';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', 'Conversation actions');
menu.appendChild(_buildSessionAction('Copy conversation link', '', '', () => {}));
menu.appendChild(_buildSessionAction('Rename conversation', '', '', () => {}));
menu.appendChild(_buildSessionAction('Delete conversation', '', '', () => {}));
_mountSessionActionMenu(menu, {session_id: 'browser-focus-test'}, trigger);
const result = {
expandedOnOpen: trigger.getAttribute('aria-expanded'),
controlsOnOpen: trigger.getAttribute('aria-controls'),
menuRole: menu.getAttribute('role'),
firstActionFocused: document.activeElement === menu.querySelector('.session-action-opt'),
firstActionRole: document.activeElement.getAttribute('role'),
};
menu.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowDown', bubbles: true}));
result.arrowDownText = document.activeElement.textContent.trim();
menu.dispatchEvent(new KeyboardEvent('keydown', {key: 'End', bubbles: true}));
result.endText = document.activeElement.textContent.trim();
menu.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true}));
result.menuRemovedOnEscape = !document.querySelector('.session-action-menu');
result.focusRestoredOnEscape = document.activeElement === trigger;
result.expandedAfterEscape = trigger.getAttribute('aria-expanded');
result.controlsAfterEscape = trigger.getAttribute('aria-controls');
row.remove();
return result;
};
""",
]
)
def test_session_action_menu_focus_lifecycle_in_browser():
try:
from playwright.sync_api import sync_playwright
except Exception: # pragma: no cover - dependency missing path
pytest.skip("playwright is unavailable; run the session action menu browser test")
with sync_playwright() as playwright:
browser = playwright.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-dev-shm-usage"],
)
page = browser.new_page()
page.set_content("<!doctype html><html><body></body></html>")
page.add_script_tag(content=_fixture_script())
result = page.evaluate("window.__sessionActionMenuFocusResult()")
browser.close()
assert result == {
"expandedOnOpen": "true",
"controlsOnOpen": "sessionActionMenu-browser-test",
"menuRole": "menu",
"firstActionFocused": True,
"firstActionRole": "menuitem",
"arrowDownText": "Rename conversation",
"endText": "Delete conversation",
"menuRemovedOnEscape": True,
"focusRestoredOnEscape": True,
"expandedAfterEscape": "false",
"controlsAfterEscape": None,
}
+35
View File
@@ -33,6 +33,41 @@ def test_session_menu_respects_motion_preferences():
assert ".session-action-menu{animation:none;will-change:auto;}" in STYLE_CSS
def test_session_action_menu_exposes_popup_state_and_focus_contract():
"""A portaled menu must stay discoverable from its sidebar trigger.
The fixed-position menu intentionally lives under document.body to avoid
clipping, so a screen reader cannot rely on DOM proximity. Opening it must
expose the popup relationship and move focus to an actionable item; Escape
must return focus to the trigger.
"""
assert SESSIONS_JS.count("menuBtn.setAttribute('aria-expanded','false');") == 2
assert "menu.setAttribute('role','menu');" in SESSIONS_JS
assert "menu.setAttribute('aria-label', 'Conversation actions');" in SESSIONS_JS
assert "opt.setAttribute('role','menuitem');" in SESSIONS_JS
mount = _sessions_block("function _mountSessionActionMenu(menu, session, anchorEl){", "function _findSessionRenameRow")
assert "anchorEl.setAttribute('aria-expanded','true');" in mount
assert "anchorEl.setAttribute('aria-controls',menu.id);" in mount
assert "firstAction.focus({preventScroll:true});" in mount
close = _sessions_block("function closeSessionActionMenu", "function _sessionActionMenuShouldIgnoreScrollTarget")
assert "_sessionActionAnchor.setAttribute('aria-expanded','false');" in close
assert "_sessionActionAnchor.removeAttribute('aria-controls');" in close
assert "restoreFocus" in close
assert "focusTarget.focus({preventScroll:true});" in close
escape_handler = _sessions_block("document.addEventListener('keydown',e=>{", "window.addEventListener('resize'")
assert "closeSessionActionMenu({restoreFocus:true});" in escape_handler
def test_session_action_menu_supports_arrow_key_navigation():
mount = _sessions_block("function _mountSessionActionMenu(menu, session, anchorEl){", "function _findSessionRenameRow")
for key in ("'ArrowDown'", "'ArrowUp'", "'Home'", "'End'"):
assert key in mount
assert "menu.addEventListener('keydown'" in mount
def test_mobile_session_menu_opens_from_long_press_and_hides_dots():
assert "const SESSION_LONG_PRESS_DELAY_MS = 400;" in SESSIONS_JS
assert "el.classList.add('long-pressing')" in SESSIONS_JS