From bc85711af2751cefa3672d1c8034468379174deb Mon Sep 17 00:00:00 2001 From: sheldon-im Date: Mon, 13 Jul 2026 21:41:02 +0900 Subject: [PATCH 1/3] Improve conversation actions menu keyboard access --- static/sessions.js | 47 ++++++++- tests/test_session_action_menu_focus.py | 126 ++++++++++++++++++++++++ tests/test_session_touch_actions.py | 35 +++++++ 3 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 tests/test_session_action_menu_focus.py diff --git a/static/sessions.js b/static/sessions.js index 4f9e9fb6..61aa8087 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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(); diff --git a/tests/test_session_action_menu_focus.py b/tests/test_session_action_menu_focus.py new file mode 100644 index 00000000..e5ac54e7 --- /dev/null +++ b/tests/test_session_action_menu_focus.py @@ -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("") + 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, + } diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index ce4d8ae8..62395174 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -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 From d7c2cc2d5d83fc25329cfb546125e18a4afcc05f Mon Sep 17 00:00:00 2001 From: sheldon-im Date: Mon, 13 Jul 2026 22:35:33 +0900 Subject: [PATCH 2/3] Restore focus from non-button session action menus --- static/sessions.js | 14 +++++-- tests/test_session_action_menu_focus.py | 52 +++++++++++++++++++++++++ tests/test_session_touch_actions.py | 4 +- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 61aa8087..c485185e 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3778,6 +3778,7 @@ let _sessionActionMenu = null; let _sessionActionAnchor = null; let _sessionActionSessionId = null; let _sessionActionMenuId = 0; +let _sessionActionPreviousFocus = null; const _expandedChildSessionKeys = new Set(); const _expandedLineageKeys = new Set(); const _lineageReportCache = new Map(); @@ -4176,8 +4177,15 @@ function _showBatchProjectPicker(){ setTimeout(()=>document.addEventListener('click',close),0); } +function _focusSessionActionMenuRestoreTarget(target){ + if(!target||!target.isConnected||typeof target.focus!=='function') return false; + try{target.focus({preventScroll:true});}catch(_){target.focus();} + return document.activeElement===target; +} + function closeSessionActionMenu({restoreFocus=false}={}){ const focusTarget=restoreFocus?_sessionActionAnchor:null; + const fallbackFocusTarget=restoreFocus?_sessionActionPreviousFocus:null; if(_sessionActionMenu){ _sessionActionMenu.remove(); _sessionActionMenu = null; @@ -4193,9 +4201,8 @@ function closeSessionActionMenu({restoreFocus=false}={}){ _sessionActionAnchor = null; } _sessionActionSessionId = null; - if(focusTarget&&focusTarget.isConnected&&typeof focusTarget.focus==='function'){ - try{focusTarget.focus({preventScroll:true});}catch(_){focusTarget.focus();} - } + _sessionActionPreviousFocus = null; + if(!_focusSessionActionMenuRestoreTarget(focusTarget)) _focusSessionActionMenuRestoreTarget(fallbackFocusTarget); } function _sessionActionMenuShouldIgnoreScrollTarget(target){ @@ -4317,6 +4324,7 @@ async function _copySessionLink(session){ } function _mountSessionActionMenu(menu, session, anchorEl){ + _sessionActionPreviousFocus=document.activeElement; document.body.appendChild(menu); _sessionActionMenu = menu; _sessionActionAnchor = anchorEl; diff --git a/tests/test_session_action_menu_focus.py b/tests/test_session_action_menu_focus.py index e5ac54e7..dea18b13 100644 --- a/tests/test_session_action_menu_focus.py +++ b/tests/test_session_action_menu_focus.py @@ -41,9 +41,11 @@ def _fixture_script() -> str: "let _sessionActionMenu = null;", "let _sessionActionAnchor = null;", "let _sessionActionSessionId = null;", + "let _sessionActionPreviousFocus = null;", "const esc = value => String(value);", "function _positionSessionActionMenu(){}", "function _playSessionActionMenuEntrance(){}", + _function_source("_focusSessionActionMenuRestoreTarget"), _function_source("closeSessionActionMenu"), _function_source("_buildSessionAction"), _function_source("_mountSessionActionMenu"), @@ -89,6 +91,33 @@ def _fixture_script() -> str: row.remove(); return result; }; + + window.__sessionActionMenuNonFocusableOpenerResult = () => { + const priorFocus = document.createElement('button'); + priorFocus.textContent = 'Prior keyboard focus'; + const row = document.createElement('div'); + row.className = 'session-item'; + row.textContent = 'Non-focusable session row'; + document.body.append(priorFocus, row); + priorFocus.focus(); + + const menu = document.createElement('div'); + menu.className = 'session-action-menu'; + menu.id = 'sessionActionMenu-nonfocusable-opener-test'; + menu.setAttribute('role', 'menu'); + menu.setAttribute('aria-label', 'Conversation actions'); + menu.appendChild(_buildSessionAction('Rename conversation', '', '', () => {})); + _mountSessionActionMenu(menu, {session_id: 'nonfocusable-opener-test'}, row); + menu.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true})); + + const result = { + menuRemovedOnEscape: !document.querySelector('.session-action-menu'), + focusReturnedToPreviousControl: document.activeElement === priorFocus, + }; + priorFocus.remove(); + row.remove(); + return result; + }; """, ] ) @@ -124,3 +153,26 @@ def test_session_action_menu_focus_lifecycle_in_browser(): "expandedAfterEscape": "false", "controlsAfterEscape": None, } + + +def test_session_action_menu_returns_to_prior_focus_for_nonfocusable_opener_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("") + page.add_script_tag(content=_fixture_script()) + result = page.evaluate("window.__sessionActionMenuNonFocusableOpenerResult()") + browser.close() + + assert result == { + "menuRemovedOnEscape": True, + "focusReturnedToPreviousControl": True, + } diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index 62395174..2e7f2af1 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -55,7 +55,9 @@ def test_session_action_menu_exposes_popup_state_and_focus_contract(): 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 + assert "fallbackFocusTarget=restoreFocus?_sessionActionPreviousFocus:null;" in close + assert "_focusSessionActionMenuRestoreTarget(focusTarget)" in close + assert "_focusSessionActionMenuRestoreTarget(fallbackFocusTarget)" in close escape_handler = _sessions_block("document.addEventListener('keydown',e=>{", "window.addEventListener('resize'") assert "closeSessionActionMenu({restoreFocus:true});" in escape_handler From d6684c091f7a793a999dc3f99e323752e7744e5b Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 13 Jul 2026 19:37:56 +0000 Subject: [PATCH 3/3] Release exp: keyboard/screen-reader access for conversation actions menu (#6019). Thanks @sheldon-im. ARIA (aria-expanded/controls, role=menu/menuitem) + focus-to-first-action on open + Arrow/Home/End/Escape nav + focus-restore-to-trigger on close; existing click/touch unchanged. Codex SAFE (isConnected-guarded restore, menu-scoped bounded arrow-nav, no listener leak [25-cycle probe], 49 tests) + stream-gate GREEN. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b50d5c96..c8874614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ - **Deep-link straight to a profile with `?profile=`.** Opening the WebUI with a `?profile=NAME` query parameter now switches to that profile on boot. The name is validated against the existing profile allowlist (a nonexistent profile boots normally, path-traversal / cross-profile attempts are rejected), the switch goes through the same access-enforced `switchToProfile`, and boot fails safe on anything invalid. Thanks @rodboev. (#5730, #5682) +- **Conversation actions menu is now keyboard- and screen-reader-accessible.** The per-conversation actions popup (the ⋯ menu in the sidebar) now exposes proper ARIA (`aria-expanded`/`aria-controls` on the trigger, `role=menu`/`menuitem` on the items), moves focus to the first action when opened, supports Arrow/Home/End navigation and Escape-to-close, and restores focus to the originating trigger on close. The existing click/touch behavior is unchanged. Thanks @sheldon-im. (#6019) + - **Model picker no longer duplicates or drops proxy/catalog models.** When the same model was reachable via both a custom proxy and a first-party catalog (e.g. `grok-4.5`), the picker could show it twice; the dedup fix collapses true duplicates to one selectable row while keeping genuinely-distinct models that merely share a name suffix — including colon-suffixed proxy models like `…:free` / `…:thinking` / `…:latest` — independently selectable. The currently-selected model always survives dedup, and model restore no longer substitutes a suffix-sharing sibling for a missing model. Thanks @rodboev. (#5997, #5989) - **Simplified Chinese (zh-CN) localization fill.** Untranslated English strings in the zh-CN locale are now translated, so Simplified Chinese users no longer see English fall-through text across recently-added UI. Thanks @Loukky. (#6024)