From a8a2a571328ce8a1d6b98743428b6511be1bd5a2 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 19 Aug 2026 11:51:34 -0400 Subject: [PATCH] Accept collaborative @ mentions with Tab In multi-user conversations the chat composer's @ mention menu only accepted the highlighted suggestion on Enter. Tab fell through to the browser default, moving focus out of the message box and leaving the half-typed @name behind. The agent-instruction mention menu already treated Tab and Enter the same way, so the chat composer was the odd one out. Extract the inline Enter selection logic into selectActiveMentionSuggestion() so every accept key routes through one implementation of the participant tag, ai_tag invocation target, and invite-confirmation branches, then add a Tab branch that reuses it. Shift+Tab and the empty-results state are deliberately left unhandled so normal focus movement still works, and the Enter guard is unchanged. Also fix the listbox semantics in the same code path: suggestions are now real options with stable ids and aria-selected, and the composer references the highlighted option via aria-activedescendant paired with aria-controls, which ARIA 1.2 requires because the menu is a sibling of the textarea rather than a descendant. Those attributes are applied only while the menu is open. The highlighted option is scrolled into view inside the height-capped menu. Fixes #1299 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../static/js/chat/chat-collaboration.js | 122 ++++++++-- ...LABORATION_MENTION_TAB_AUTOCOMPLETE_FIX.md | 80 +++++++ docs/explanation/fixes/index.md | 1 + docs/explanation/release_notes.md | 16 ++ ..._collaboration_mention_tab_autocomplete.py | 225 ++++++++++++++++++ ...hat_collaboration_mention_tab_selection.py | 211 ++++++++++++++++ 7 files changed, 639 insertions(+), 18 deletions(-) create mode 100644 docs/explanation/fixes/COLLABORATION_MENTION_TAB_AUTOCOMPLETE_FIX.md create mode 100644 functional_tests/test_collaboration_mention_tab_autocomplete.py create mode 100644 ui_tests/test_chat_collaboration_mention_tab_selection.py diff --git a/application/single_app/config.py b/application/single_app/config.py index e3f9a3646..7a4b93be9 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -96,7 +96,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.260.003" +VERSION = "0.260.004" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/static/js/chat/chat-collaboration.js b/application/single_app/static/js/chat/chat-collaboration.js index c6bcbfe4a..b0af42d42 100644 --- a/application/single_app/static/js/chat/chat-collaboration.js +++ b/application/single_app/static/js/chat/chat-collaboration.js @@ -24,6 +24,7 @@ const RECENT_COLLABORATORS_KEY = 'recentCollaborators'; const MAX_RECENT_COLLABORATORS = 12; const DEFAULT_SUGGESTION_LIMIT = 8; const SEARCH_HIGHLIGHT_MAX_AGE_MS = 30000; +const MENTION_OPTION_ID_PREFIX = 'collaboration-mention-option-'; const mentionMenu = document.getElementById('collaboration-mention-menu'); const participantModalEl = document.getElementById('collaboration-participant-modal'); @@ -1509,7 +1510,32 @@ function buildSuggestionItemHtml(suggestion) { `; } +function applyMentionComboboxState(activeItemId) { + if (!userInput || !mentionMenu) { + return; + } + + // ARIA 1.2 lets a focused textbox point aria-activedescendant at a descendant + // of the element named by aria-controls, which is how the composer announces + // the highlighted suggestion without moving focus out of the message box. + userInput.setAttribute('aria-controls', mentionMenu.id); + userInput.setAttribute('aria-autocomplete', 'list'); + userInput.setAttribute('aria-activedescendant', activeItemId); +} + +function clearMentionComboboxState() { + if (!userInput) { + return; + } + + userInput.removeAttribute('aria-activedescendant'); + userInput.removeAttribute('aria-autocomplete'); + userInput.removeAttribute('aria-controls'); +} + function hideMentionMenu() { + clearMentionComboboxState(); + if (!mentionMenu) { return; } @@ -1525,13 +1551,14 @@ function renderMentionMenu(results, mentionState) { } if (!Array.isArray(results) || results.length === 0) { - mentionMenu.innerHTML = '
No matching participants, agents, models, or collaborators found.
'; + mentionMenu.innerHTML = '
No matching participants, agents, models, or collaborators found.
'; mentionMenu.classList.remove('d-none'); activeMentionState = { ...mentionState, results: [], activeIndex: -1, }; + clearMentionComboboxState(); return; } @@ -1545,7 +1572,10 @@ function renderMentionMenu(results, mentionState) { results.forEach((result, index) => { const button = document.createElement('button'); button.type = 'button'; + button.id = `${MENTION_OPTION_ID_PREFIX}${index}`; button.className = `list-group-item list-group-item-action collaboration-mention-item${index === 0 ? ' active' : ''}`; + button.setAttribute('role', 'option'); + button.setAttribute('aria-selected', index === 0 ? 'true' : 'false'); button.innerHTML = buildSuggestionItemHtml(result); button.setAttribute('data-index', String(index)); button.addEventListener('mousedown', event => { @@ -1569,17 +1599,39 @@ function renderMentionMenu(results, mentionState) { mentionMenu.appendChild(button); }); mentionMenu.classList.remove('d-none'); + updateMentionMenuActiveItem({ scrollActiveIntoView: false }); } -function updateMentionMenuActiveItem() { +function updateMentionMenuActiveItem({ scrollActiveIntoView = true } = {}) { if (!mentionMenu || !activeMentionState) { return; } const items = mentionMenu.querySelectorAll('.collaboration-mention-item'); + let activeItemId = ''; items.forEach((item, index) => { - item.classList.toggle('active', index === activeMentionState.activeIndex); + const isActive = index === activeMentionState.activeIndex; + item.classList.toggle('active', isActive); + item.setAttribute('aria-selected', isActive ? 'true' : 'false'); + if (isActive) { + activeItemId = item.id || ''; + // The menu is height-capped and scrollable, so keep the highlighted + // suggestion visible while the user arrows through the list. + if (scrollActiveIntoView && typeof item.scrollIntoView === 'function') { + item.scrollIntoView({ block: 'nearest' }); + } + } }); + + if (!userInput) { + return; + } + + if (activeItemId) { + applyMentionComboboxState(activeItemId); + } else { + clearMentionComboboxState(); + } } async function refreshMentionSuggestions() { @@ -1885,6 +1937,42 @@ function handleComposerInput() { void refreshMentionSuggestions(); } +function selectActiveMentionSuggestion() { + if (!activeMentionState || !Array.isArray(activeMentionState.results)) { + return false; + } + + const mentionState = activeMentionState; + const collaborator = mentionState.results[mentionState.activeIndex]; + if (!collaborator) { + return false; + } + + if (collaborator.action === 'tag') { + insertParticipantMention(collaborator, mentionState); + } else if (collaborator.action === 'ai_tag') { + insertInvocationTargetMention(collaborator, mentionState); + } else { + openParticipantConfirmation(collaborator, { + conversationId: window.chatConversations?.getCurrentConversationId?.(), + source: 'mention', + mentionState, + }); + } + + return true; +} + +function hasActiveMentionSuggestion() { + return Boolean( + activeMentionState + && Array.isArray(activeMentionState.results) + && activeMentionState.results.length > 0 + && activeMentionState.activeIndex >= 0 + && activeMentionState.results[activeMentionState.activeIndex], + ); +} + function handleComposerKeydown(event) { if (!activeMentionState || mentionMenu?.classList.contains('d-none')) { if (event.key === 'Escape' && activeReplyContext) { @@ -1908,22 +1996,22 @@ function handleComposerKeydown(event) { return true; } + // Tab accepts the highlighted suggestion the same way Enter does. Shift+Tab is + // deliberately left alone so it keeps moving focus backwards, and Tab falls + // through whenever there is nothing highlighted to accept. + if (event.key === 'Tab' && !event.shiftKey) { + if (!hasActiveMentionSuggestion()) { + return false; + } + + event.preventDefault(); + selectActiveMentionSuggestion(); + return true; + } + if (event.key === 'Enter' && activeMentionState.activeIndex >= 0) { event.preventDefault(); - const collaborator = activeMentionState.results[activeMentionState.activeIndex]; - if (collaborator) { - if (collaborator.action === 'tag') { - insertParticipantMention(collaborator, activeMentionState); - } else if (collaborator.action === 'ai_tag') { - insertInvocationTargetMention(collaborator, activeMentionState); - } else { - openParticipantConfirmation(collaborator, { - conversationId: window.chatConversations?.getCurrentConversationId?.(), - source: 'mention', - mentionState: activeMentionState, - }); - } - } + selectActiveMentionSuggestion(); return true; } diff --git a/docs/explanation/fixes/COLLABORATION_MENTION_TAB_AUTOCOMPLETE_FIX.md b/docs/explanation/fixes/COLLABORATION_MENTION_TAB_AUTOCOMPLETE_FIX.md new file mode 100644 index 000000000..b8f02d8ed --- /dev/null +++ b/docs/explanation/fixes/COLLABORATION_MENTION_TAB_AUTOCOMPLETE_FIX.md @@ -0,0 +1,80 @@ +# Collaboration Mention Tab Autocomplete Fix + +Fixed in version: **0.260.004** + +Related issue: [#1299](https://github.com/microsoft/simplechat/issues/1299) + +## Issue Description + +In multi-user (collaborative) conversations, typing `@` in the chat composer opens the participant and AI target suggestion menu. Arrow keys moved the highlight and Enter accepted the highlighted entry, but Tab did nothing to the menu. + +Because Tab fell through to the browser default, pressing it moved focus out of the message box, the menu closed on blur, and the partially typed `@par` text was left behind. Most users expect Tab to complete an autocomplete entry, so the mention menu felt broken even though Enter worked. + +The behavior was also inconsistent with the agent-instruction mention menu (`static/js/agent_instruction_mentions.js`), which already treated Tab and Enter as the same "accept" action. + +## Root Cause Analysis + +- `handleComposerKeydown()` in `application/single_app/static/js/chat/chat-collaboration.js` only branched on `ArrowDown`, `ArrowUp`, `Enter`, and `Escape`. There was no `Tab` branch, so the function returned `false`. +- The `#user-input` keydown listener in `chat-messages.js` only short-circuits when `window.chatCollaboration.handleComposerKeydown(e)` returns `true`. A `false` return meant the browser applied its default focus-movement behavior for Tab. +- The selection logic (participant tag vs. AI invocation target vs. invite confirmation) was written inline inside the `Enter` branch, so there was no reusable entry point another key could call. + +A related accessibility gap surfaced in the same code path: `#collaboration-mention-menu` is declared `role="listbox"` in `templates/chats.html`, but `renderMentionMenu()` created plain `