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) diff --git a/static/sessions.js b/static/sessions.js index 4f9e9fb6..c485185e 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3777,6 +3777,8 @@ _restoreSessionSourceFilter(); 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(); @@ -4175,7 +4177,15 @@ function _showBatchProjectPicker(){ setTimeout(()=>document.addEventListener('click',close),0); } -function closeSessionActionMenu(){ +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; @@ -4183,12 +4193,16 @@ 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; + _sessionActionPreviousFocus = null; + if(!_focusSessionActionMenuRestoreTarget(focusTarget)) _focusSessionActionMenuRestoreTarget(fallbackFocusTarget); } function _sessionActionMenuShouldIgnoreScrollTarget(target){ @@ -4240,6 +4254,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 @@ -4309,15 +4324,44 @@ async function _copySessionLink(session){ } function _mountSessionActionMenu(menu, session, anchorEl){ + _sessionActionPreviousFocus=document.activeElement; document.body.appendChild(menu); _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 +4666,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 +4859,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 +8297,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 +8396,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..dea18b13 --- /dev/null +++ b/tests/test_session_action_menu_focus.py @@ -0,0 +1,178 @@ +"""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;", + "let _sessionActionPreviousFocus = null;", + "const esc = value => String(value);", + "function _positionSessionActionMenu(){}", + "function _playSessionActionMenuEntrance(){}", + _function_source("_focusSessionActionMenuRestoreTarget"), + _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; + }; + + 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; + }; + """, + ] + ) + + +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, + } + + +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 ce4d8ae8..2e7f2af1 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -33,6 +33,43 @@ 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 "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 + + +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