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 `