diff --git a/application/single_app/config.py b/application/single_app/config.py index 7a4b93be9..0f2645f31 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.004" +VERSION = "0.260.005" 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..a6be6986c --- /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.005** + +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 `