diff --git a/docs/engineering/unified_conversations_and_participant_profiles.md b/docs/engineering/unified_conversations_and_participant_profiles.md
index e8901342..3265a603 100644
--- a/docs/engineering/unified_conversations_and_participant_profiles.md
+++ b/docs/engineering/unified_conversations_and_participant_profiles.md
@@ -259,3 +259,26 @@ resolution, renamed/reordered sources and unavailable/denied turns;
`tests/browser/turnReferenceMenu.cjs` checks the production frontend modules in
a local HTTP fixture with real browser clipboard access and reloads. That
fixture does not claim live authenticated-server or public deployment acceptance.
+
+### Shared conversation draft editor
+
+Von, Messaging replies and the new-message dialog use `conversationDraft.js`
+for concept autocomplete, cartouche editing, desktop Enter/mobile newline
+behaviour, draft sizing and history navigation. Autocomplete consumes its
+selection keys before history or submission. The shared control presentation
+and compact layout use `conversationDraftControls.js` and `compactComposer.js`;
+Messaging dictation uses the same recorded, streaming and browser controller as
+Von, including finish-before-send, cancellation and retry.
+
+Carrier adapters retain draft scope, attachment ownership and delivery recovery.
+Cartouche IDs are normalised before message and recipient submission. Restoring
+an existing draft refreshes its overlay without invalidating its delivery key.
+Von-only execution actions (model selection, steering, reset and conversational
+voice replies) remain with the Von carrier; direct messages retain addressed
+message delivery rather than acquiring an AI execution session.
+
+Local fixture browser coverage: `tests/browser/messageComposer.cjs` exercises
+production Messaging modules/CSS with fixture APIs at desktop and phone widths.
+It checks concept selection without accidental submission, normalised delivery,
+new-message editing, shared controls and compact layout. This is not evidence of
+public authentication, live recipient delivery or audio-provider availability.
diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js
index bd0e1bbb..bf935a08 100644
--- a/src/frontend/web/von_interface/static/js/chatTab.js
+++ b/src/frontend/web/von_interface/static/js/chatTab.js
@@ -1,9 +1,11 @@
+import { initialiseDraftControls } from './components/conversationDraftControls.js';
+import { initialiseConversationDraft, resizeConversationDraft, navigateConversationDraftHistory } from './components/conversationDraft.js';
import { createTurnModelPicker } from './components/turnModelPicker.js';
import { copyCompactConversationReference, isConversationConceptReference, conversationReferenceMetadata, readConversationReference } from './utils/conversationReference.js';
import { bindConversationRowMenu } from './components/conversationRowMenu.js';
import { initialiseConversationActions } from './components/conversationActions.js';
import { createLatestMessageButton, setNavigationVisible, focusConversationTarget } from './components/conversationNavigation.js';
-import { initialiseCompactChatComposer, isCompactComposer, resizeCompactDraft, shouldSubmitComposerKey } from './components/compactComposer.js';
+import { initialiseCompactChatComposer, isCompactComposer } from './components/compactComposer.js';
import { updateExecutionCost } from './components/executionCost.js';
import { canUseWorkflowStudio } from './workflowStudioAccess.js';
import { createChatSteeringControls } from './components/chatSteeringControls.js';
@@ -26,10 +28,9 @@ import {
isPremiumModelProvider,
resolveLocalRequestedLlm,
} from './utils/localModelPreferences.js';
-import { initializeConceptAutocomplete } from './components/conceptAutocomplete.js';
import { initializeMessagePanel, loadUnreadCount } from './components/messagePanel.js';
import { loadMyOrganisations, synchroniseOrganisationContext } from './components/orgSelector.js';
-import { initializePromptCartoucheOverlay, normaliseVontologyIdsForBackend } from './components/promptCartoucheOverlay.js';
+import { normaliseVontologyIdsForBackend } from './components/promptCartoucheOverlay.js';
import { initializeTaskPanel, isTaskPanelVisible, setCurrentSession as setTaskPanelSession, toggleTaskPanel } from './components/taskPanel.js';
import {
decorateInspectableReferences,
@@ -21305,55 +21306,14 @@ function rehydrateLastSubmittedUserPromptFromHistory(historyMessages) {
}
function handlePromptInputHistoryNavigation(event) {
- if (!event) return;
- if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
- if (event.ctrlKey || event.altKey || event.metaKey) return;
-
- const promptInput = event.currentTarget || getPromptInputElement();
- if (!promptInput) return;
-
- const history = getPromptHistoryForActiveSession();
- if (history.length === 0) return;
-
- let cursor = getPromptHistoryCursorForActiveSession();
- let nextValue;
- const isShiftJump = event.shiftKey;
- const promptValue = String(promptInput.value || '');
- const isCurrentHistoryValue = cursor >= 0 && cursor < history.length && history[cursor] === promptValue;
- const allowsHistoryNavigation = promptValue === '' || isCurrentHistoryValue;
- if (!allowsHistoryNavigation) return;
-
- if (event.key === 'ArrowUp') {
- cursor = isShiftJump ? 0 : Math.max(0, cursor - 1);
- nextValue = history[cursor];
- } else {
- if (isShiftJump) {
- cursor = Math.max(0, history.length - 1);
- } else {
- if (cursor >= history.length) return;
- cursor = cursor + 1;
- }
- if (cursor >= history.length) {
- setPromptHistoryCursorForActiveSession(cursor);
- event.preventDefault();
- setPromptComposerValue('', { promptInput });
- return;
- }
- nextValue = history[cursor];
- }
-
- if (!nextValue) return;
-
- setPromptHistoryCursorForActiveSession(cursor);
-
- event.preventDefault();
- setPromptComposerValue(nextValue, { promptInput });
- try {
- const len = promptInput.value.length;
- promptInput.setSelectionRange(len, len);
- } catch (_) {
- // Ignore browsers/environments without setSelectionRange support.
- }
+ const input = event?.currentTarget || getPromptInputElement();
+ if (!input) return;
+ const next = navigateConversationDraftHistory(event, input,
+ getPromptHistoryForActiveSession(), getPromptHistoryCursorForActiveSession());
+ if (!next) return;
+ setPromptHistoryCursorForActiveSession(next.cursor);
+ setPromptComposerValue(next.value, { promptInput: input });
+ input.setSelectionRange(input.value.length, input.value.length);
}
function dispatchPromptComposerInputEvent(promptInput = getPromptInputElement()) {
@@ -31153,14 +31113,7 @@ function handleConversationScrollPositionChange(scrollableField) {
}
function resizePromptComposer(promptInput) {
- if (!(promptInput instanceof HTMLTextAreaElement)) return;
- if (isCompactComposer()) { resizeCompactDraft(promptInput); return; }
- promptInput.style.height = 'auto';
- const minHeight = 44;
- const maxHeight = 144;
- const nextHeight = Math.min(maxHeight, Math.max(minHeight, Number(promptInput.scrollHeight) || minHeight));
- promptInput.style.height = `${nextHeight}px`;
- promptInput.style.overflowY = (Number(promptInput.scrollHeight) || 0) > maxHeight ? 'auto' : 'hidden';
+ resizeConversationDraft(promptInput);
}
function setLatestUnreadBoundary(messageElement) {
@@ -34317,6 +34270,7 @@ export function initializeChatTab() {
}
// Shared mobile/desktop recording lifecycle; browser recognition is explicit.
+ initialiseDraftControls({ sendButton, attachmentButton: document.getElementById('attachImageButton'), dictateButton });
dictationController?.dispose();
if (dictateButton && promptInput) {
dictationController = createDictationController({
@@ -34368,7 +34322,7 @@ export function initializeChatTab() {
updateSendButtonForCurrentChatState();
if (!chatSteeringControls?.activate(event)) void handleSendPrompt();
};
- sendButton.addEventListener('click', submitComposer);
+
resetButton.addEventListener('click', handleResetContext);
ensureAbortButtonBound();
@@ -34380,30 +34334,15 @@ export function initializeChatTab() {
setSelectedChatPromptQueueEntryId(null);
});
- promptInput.addEventListener('input', function () {
- setSelectedChatPromptQueueEntryId(null);
- resizePromptComposer(promptInput);
- updateSendButtonDraftReadiness();
- });
- resizePromptComposer(promptInput);
-
- promptInput.addEventListener('keypress', function (event) {
- if (shouldSubmitComposerKey(event)) {
- event.preventDefault();
- submitComposer(event);
- }
+ initialiseConversationDraft({
+ input: promptInput, sendButton, onSubmit: submitComposer,
+ onInput: () => {
+ setSelectedChatPromptQueueEntryId(null);
+ updateSendButtonDraftReadiness();
+ },
+ onHistory: handlePromptInputHistoryNavigation
});
- // JVNAUTOSCI-2128/2163: ArrowUp/ArrowDown on an empty composer recalls
- // historical submitted prompts with cursor navigation.
- promptInput.addEventListener('keydown', handlePromptInputHistoryNavigation);
-
- // Initialize concept autocomplete for #V# trigger
- initializeConceptAutocomplete(promptInput);
-
- // Render non-trigger (#V\u200B#...) concept tokens as cartouches in the prompt.
- initializePromptCartoucheOverlay(promptInput);
-
void Promise.allSettled([
loadRecentChatPair({
scrollToBottom: true,
diff --git a/src/frontend/web/von_interface/static/js/components/compactComposer.js b/src/frontend/web/von_interface/static/js/components/compactComposer.js
index b15d20a6..ffd6ad7d 100644
--- a/src/frontend/web/von_interface/static/js/components/compactComposer.js
+++ b/src/frontend/web/von_interface/static/js/components/compactComposer.js
@@ -28,7 +28,7 @@ export function resizeCompactDraft(input) {
input.style.overflowY = input.scrollHeight > height ? 'auto' : 'hidden';
}
-export function initialiseCompactChatComposer(composer, resizeDraft = resizeCompactDraft) {
+export function initialiseCompactChatComposer(composer, resizeDraft = resizeCompactDraft, elements = {}) {
if (!composer || composer.dataset.compactBound) return;
composer.dataset.compactBound = 'true';
const menu = composer.querySelector('.chat-composer-more-actions');
@@ -80,19 +80,19 @@ export function initialiseCompactChatComposer(composer, resizeDraft = resizeComp
if (menu.isConnected && isCompactComposer() && !event.composedPath().includes(menu)) dismiss();
});
// Keyboard activation also dismisses before the existing submission handler.
- composer.querySelector('#sendButton')?.addEventListener('click', () => {
+ (elements.send || composer.querySelector('#sendButton'))?.addEventListener('click', () => {
if (isCompactComposer()) dismiss();
}, { capture: true });
const active = document.createElement('div');
active.className = 'compact-composer-active';
composer.append(active);
- const input = composer.querySelector('#promptInput');
- const mic = composer.querySelector('#dictateButton');
- const voice = composer.querySelector('#voiceConversationButton');
- const status = composer.querySelector('#dictationStatus');
- const voiceStatus = composer.querySelector('#voiceConversationStatus');
- const cancel = composer.querySelector('#cancelDictationButton');
- const retry = composer.querySelector('#retryDictationButton');
+ const input = elements.input || composer.querySelector('#promptInput');
+ const mic = elements.mic || composer.querySelector('#dictateButton');
+ const voice = elements.voice || composer.querySelector('#voiceConversationButton');
+ const status = elements.status || composer.querySelector('#dictationStatus');
+ const voiceStatus = elements.voiceStatus || composer.querySelector('#voiceConversationStatus');
+ const cancel = elements.cancel || composer.querySelector('#cancelDictationButton');
+ const retry = elements.retry || composer.querySelector('#retryDictationButton');
const homes = new Map([mic, voice, status, voiceStatus, cancel, retry].filter(Boolean).map(node => {
const home = document.createComment(`desktop ${node.id}`);
node.before(home);
@@ -139,7 +139,7 @@ export function initialiseCompactChatComposer(composer, resizeDraft = resizeComp
}
});
menu.addEventListener('click', event => {
- if (isCompactComposer() && event.target.closest('#dictateButton, #voiceConversationButton, #uploadFileButton')) {
+ if (isCompactComposer() && event.target.closest('#dictateButton, .conversation-dictate-button, #voiceConversationButton, #uploadFileButton')) {
// Recording controls become visible on the controller's next render.
dismiss(true);
}
diff --git a/src/frontend/web/von_interface/static/js/components/conceptAutocomplete.js b/src/frontend/web/von_interface/static/js/components/conceptAutocomplete.js
index 4f269d20..db81e170 100644
--- a/src/frontend/web/von_interface/static/js/components/conceptAutocomplete.js
+++ b/src/frontend/web/von_interface/static/js/components/conceptAutocomplete.js
@@ -627,7 +627,8 @@ export function initializeConceptAutocomplete(textareaElement) {
const state = getOrCreateState(textareaElement);
// Delay to allow click on dropdown items
setTimeout(() => {
- if (!state.dropdownPointerDown) {
+ if (activeTextarea === textareaElement && document.activeElement !== textareaElement
+ && !state.dropdownPointerDown) {
closeAutocomplete();
}
}, 150);
diff --git a/src/frontend/web/von_interface/static/js/components/conversationDraft.js b/src/frontend/web/von_interface/static/js/components/conversationDraft.js
new file mode 100644
index 00000000..89a9f38c
--- /dev/null
+++ b/src/frontend/web/von_interface/static/js/components/conversationDraft.js
@@ -0,0 +1,66 @@
+import { initializeConceptAutocomplete } from './conceptAutocomplete.js';
+import { initializePromptCartoucheOverlay, normaliseVontologyIdsForBackend } from './promptCartoucheOverlay.js';
+import { isCompactComposer, resizeCompactDraft, shouldSubmitComposerKey } from './compactComposer.js';
+
+export { normaliseVontologyIdsForBackend };
+
+// All conversation drafts use the Von editor, including its token rendering and
+// keyboard ordering. Delivery, attachment ownership and history stay with callers.
+export function resizeConversationDraft(input) {
+ if (!(input instanceof HTMLTextAreaElement)) return;
+ input.dataset.hasDraft = String(Boolean(input.value.trim()));
+ if (isCompactComposer()) { resizeCompactDraft(input); return; }
+ input.style.height = 'auto';
+ const height = Math.min(144, Math.max(44, Number(input.scrollHeight) || 44));
+ input.style.height = `${height}px`;
+ input.style.overflowY = input.scrollHeight > 144 ? 'auto' : 'hidden';
+}
+
+export function setConversationDraft(input, value) {
+ if (!input) return;
+ input.value = value;
+ const event = new Event('input', { bubbles: true });
+ event.conversationDraftRestored = true;
+ input.dispatchEvent(event);
+}
+
+export function initialiseConversationDraft({ input, sendButton, onSubmit, onInput, onHistory }) {
+ if (!input || input.dataset.conversationDraftBound) return;
+ input.dataset.conversationDraftBound = 'true';
+ input.classList.add('prompt-input');
+ input.rows = 1;
+ initializeConceptAutocomplete(input);
+ initializePromptCartoucheOverlay(input);
+ // Autocomplete must get first refusal: Enter selects a concept, never sends it.
+ input.addEventListener('keydown', event => {
+ if (event.defaultPrevented) return;
+ onHistory?.(event);
+ if (!event.defaultPrevented && shouldSubmitComposerKey(event)) {
+ event.preventDefault();
+ onSubmit(event);
+ }
+ });
+ sendButton?.addEventListener('click', onSubmit);
+ input.addEventListener('input', event => {
+ resizeConversationDraft(input);
+ if (!event.conversationDraftRestored) onInput?.();
+ });
+ const resize = () => { if (input.isConnected) resizeConversationDraft(input); };
+ window.addEventListener('resize', resize);
+ window.visualViewport?.addEventListener('resize', resize);
+ resize();
+}
+
+export function navigateConversationDraftHistory(event, input, history, cursor) {
+ if (!event || !['ArrowUp', 'ArrowDown'].includes(event.key)
+ || event.ctrlKey || event.altKey || event.metaKey || event.isComposing || !history.length) return null;
+ const value = input.value || '';
+ if (value !== '' && history[cursor] !== value) return null;
+ let next = event.key === 'ArrowUp'
+ ? (event.shiftKey ? 0 : Math.max(0, cursor - 1))
+ : (event.shiftKey ? history.length - 1 : cursor + 1);
+ next = Math.min(history.length, next);
+ if (event.key === 'ArrowDown' && cursor >= history.length && !event.shiftKey) return null;
+ event.preventDefault();
+ return { cursor: next, value: history[next] || '' };
+}
diff --git a/src/frontend/web/von_interface/static/js/components/conversationDraftControls.js b/src/frontend/web/von_interface/static/js/components/conversationDraftControls.js
new file mode 100644
index 00000000..a8ce876d
--- /dev/null
+++ b/src/frontend/web/von_interface/static/js/components/conversationDraftControls.js
@@ -0,0 +1,71 @@
+import { initialiseCompactChatComposer } from './compactComposer.js';
+import { resizeConversationDraft } from './conversationDraft.js';
+// Shared presentation for the controls supported by every conversation carrier.
+export const SEND_ICON = '';
+const IMAGE_ICON = '';
+const MICROPHONE_ICON = '';
+
+export function styleDraftButton(button, kind, label) {
+ if (!button) return;
+ button.classList.add('btn', kind === 'send' ? 'btn-primary' : 'btn-secondary', 'composer-icon-button');
+ button.type = 'button';
+ button.classList.add(`conversation-${kind}-button`);
+ button.innerHTML = ({ send: SEND_ICON, attachment: IMAGE_ICON, dictation: MICROPHONE_ICON })[kind];
+ const name = document.createElement('span');
+ name.className = 'button-label sr-only';
+ name.textContent = label;
+ button.append(name);
+ button.setAttribute('aria-label', label);
+ button.title = label;
+}
+
+export function initialiseDraftControls({ sendButton, attachmentButton, dictateButton }) {
+ styleDraftButton(sendButton, 'send', 'Send message');
+ styleDraftButton(attachmentButton, 'attachment', attachmentButton?.getAttribute('aria-label') || 'Attach image');
+ styleDraftButton(dictateButton, 'dictation', 'Dictate');
+}
+
+export function createMessageDraftControls(root, input, sendButton) {
+ const actions = document.createElement('div');
+ actions.className = 'chat-composer-actions';
+ const primary = document.createElement('div');
+ primary.className = 'chat-composer-primary-actions';
+ const button = document.createElement('button');
+ const attachmentButton = root.querySelector('.message-attachment-button');
+ primary.append(sendButton);
+ if (attachmentButton) primary.append(attachmentButton);
+ primary.append(button);
+ const menu = document.createElement('details');
+ menu.className = 'chat-composer-more-actions';
+ menu.innerHTML = `More actions⋯+
+
`;
+ const status = document.createElement('p');
+ status.className = 'speech-settings-note'; status.role = 'status';
+ status.setAttribute('aria-live', 'polite');
+ const cancelButton = document.createElement('button');
+ cancelButton.type = 'button'; cancelButton.className = 'btn btn-secondary';
+ cancelButton.textContent = 'Cancel dictation'; cancelButton.hidden = true;
+ const retryButton = cancelButton.cloneNode(true); retryButton.textContent = 'Retry recording';
+ primary.append(cancelButton, retryButton);
+ actions.append(primary, menu);
+ const row = input.closest('.message-compose-row, .message-attachment-input-row');
+ const shell = document.createElement('div');
+ shell.className = 'chat-composer message-draft-shell';
+ row.before(shell);
+ input.classList.add('conversation-draft-input');
+ shell.append(input, actions, status);
+ row.remove();
+ initialiseDraftControls({ sendButton, attachmentButton, dictateButton: button });
+ button.classList.add('conversation-dictate-button');
+ cancelButton.dataset.draftRecovery = 'true'; retryButton.dataset.draftRecovery = 'true';
+ initialiseCompactChatComposer(shell, resizeConversationDraft, {
+ input, send: sendButton, mic: button, status, cancel: cancelButton, retry: retryButton
+ });
+ return { button, status, cancelButton, retryButton, engineSelect: menu.querySelector('select') };
+}
diff --git a/src/frontend/web/von_interface/static/js/components/messagePanel.js b/src/frontend/web/von_interface/static/js/components/messagePanel.js
index 1444db20..f1e45ff7 100644
--- a/src/frontend/web/von_interface/static/js/components/messagePanel.js
+++ b/src/frontend/web/von_interface/static/js/components/messagePanel.js
@@ -1,7 +1,11 @@
+import { createDictationController } from '../dictation.js';
+import { createMessageDraftControls, SEND_ICON as SEND_MESSAGE_ICON } from './conversationDraftControls.js';
import { initialiseConversationActions } from './conversationActions.js';
import { createLatestMessageButton, setNavigationVisible, focusConversationTarget } from './conversationNavigation.js';
import { bindAttachmentComposer, imageItems, imagesBlocked, renderImageAttachments, removeSentAttachments } from '../utils/conversationImages.js';
import { getWindowSessionId, WINDOW_SESSION_HEADER } from '../apiService.js';
+const messageDictation = new Map();
+const messageHistoryCursors = new Map();
let refreshReplyAttachments = () => {};
let refreshNewAttachments = () => {};
const attachmentSignatures = new Map();
@@ -15,7 +19,7 @@ function attachmentDraftChanged(scope) {
function attachmentScope(scope) {
return `messages:${scope}:${scope === 'reply' ? (exchangeScope() || _currentConversationUserId) : readSessionActorConceptId()}`;
}
-import { resizeCompactDraft, shouldSubmitComposerKey } from './compactComposer.js';
+import { initialiseConversationDraft, navigateConversationDraftHistory, resizeConversationDraft as resizeCompactDraft, setConversationDraft, normaliseVontologyIdsForBackend } from './conversationDraft.js';
import { initializeConceptAutocomplete } from './conceptAutocomplete.js';
import { simpleMarkdownToHtml } from '../markdownUtils.js';
import { profileButton, participantAvatar } from './participantProfile.js';
@@ -37,9 +41,6 @@ import {
renderRecommendationReviewPayload,
} from './paperRecommendationUi.js';
-// Match the conversation composer’s send icon; Messages retains direct delivery semantics.
-const SEND_MESSAGE_ICON = '';
-
// Message panel state
let _messagesContainer = null;
let _messageListEl = null;
@@ -71,6 +72,8 @@ function exchangeScope(row = _exchange) {
}
export function resetMessagePanelContext() {
+ for (const controller of messageDictation.values()) controller.cancel();
+ messageHistoryCursors.clear();
clearReplyContext();
const input = _messagesContainer?.querySelector('#messageInput');
if (_exchange && input) _exchangeDrafts.set(exchangeScope(), input.value);
@@ -108,7 +111,7 @@ export async function openMessageExchange(row) {
}
const input = _messagesContainer?.querySelector('#messageInput');
if (input) {
- input.value = _exchangeDrafts.get(exchangeScope()) || '';
+ setConversationDraft(input, _exchangeDrafts.get(exchangeScope()) || '');
resizeCompactDraft(input);
input.placeholder = `Reply to ${row.session_name}`;
}
@@ -392,6 +395,8 @@ function resetMessagesViewportPosition() {
* Render the messages tab content structure.
*/
function renderMessagesTabContent() {
+ for (const controller of messageDictation.values()) controller.dispose();
+ messageDictation.clear();
if (!_messagesContainer) return;
syncVisibleReplyDeliveryAttempt();
@@ -491,6 +496,24 @@ function renderMessagesTabContent() {
_replySendFailureState = null;
_newMessageSendFailureState = null;
+ for (const [scope, root, input, sendButton] of [
+ [COMPOSE_SCOPE_REPLY, _messagesContainer.querySelector('#messageComposeArea'), _messagesContainer.querySelector('#messageInput'), _messagesContainer.querySelector('#sendMessageBtn')],
+ [COMPOSE_SCOPE_NEW_MESSAGE, _messagesContainer.querySelector('.message-modal-body'), _messagesContainer.querySelector('#newMessageContent'), _messagesContainer.querySelector('#sendNewMessage')]
+ ]) {
+ const controls = createMessageDraftControls(root, input, sendButton);
+ messageDictation.set(scope, createDictationController({
+ input, ...controls,
+ getContext: () => ({ key: JSON.stringify([attachmentScope(scope), readSessionActorConceptId(), getSessionScopedOrgId(),
+ scope === COMPOSE_SCOPE_NEW_MESSAGE ? _messagesContainer.querySelector('#newMessageRecipient')?.value : _currentConversationUserId]),
+ text: normaliseVontologyIdsForBackend(input.value), language: navigator.language }),
+ setValue: value => {
+ setConversationDraft(input, value);
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ },
+ fetchImpl: (url, options = {}) => fetch(url, { ...options, headers: { ...options.headers, [WINDOW_SESSION_HEADER]: getWindowSessionId() } })
+ }));
+ }
+
// Attach event listeners
attachMessagesEventListeners();
initialiseConversationActions(_messagesContainer);
@@ -522,26 +545,30 @@ function attachMessagesEventListeners() {
});
}
- // Send message button
- const sendBtn = _messagesContainer.querySelector('#sendMessageBtn');
- if (sendBtn) {
- sendBtn.addEventListener('click', handleSendReply);
- }
-
- // Message input - send on Enter (Shift+Enter for newline)
- const msgInput = _messagesContainer.querySelector('#messageInput');
- if (msgInput) {
- msgInput.addEventListener('keydown', (e) => {
- if (shouldSubmitComposerKey(e)) {
- e.preventDefault();
- handleSendReply();
- }
- });
- msgInput.addEventListener('input', syncVisibleReplyDeliveryAttempt);
- resizeCompactDraft(msgInput);
- window.addEventListener('resize', resizeVisibleReplyComposer);
- window.visualViewport?.addEventListener('resize', resizeVisibleReplyComposer);
- }
+ initialiseConversationDraft({
+ input: _messagesContainer.querySelector('#messageInput'),
+ sendButton: _messagesContainer.querySelector('#sendMessageBtn'),
+ onSubmit: handleSendReply,
+ onInput: syncVisibleReplyDeliveryAttempt,
+ onHistory: event => {
+ const history = _currentMessages.filter(message => message.relationships?.['#V#has_sender']?.[0] === _currentUserId)
+ .map(message => message.concept_data?.content_fallback || '').filter(Boolean);
+ const key = attachmentScope(COMPOSE_SCOPE_REPLY);
+ const next = navigateConversationDraftHistory(event, event.currentTarget, history,
+ messageHistoryCursors.get(key) ?? history.length);
+ if (!next) return;
+ messageHistoryCursors.set(key, next.cursor);
+ setConversationDraft(event.currentTarget, next.value);
+ event.currentTarget.setSelectionRange(next.value.length, next.value.length);
+ syncVisibleReplyDeliveryAttempt();
+ }
+ });
+ initialiseConversationDraft({
+ input: _messagesContainer.querySelector('#newMessageContent'),
+ sendButton: _messagesContainer.querySelector('#sendNewMessage'),
+ onSubmit: handleSendNewMessage,
+ onInput: syncVisibleNewMessageDeliveryAttempt
+ });
const replyRecoverySelect = _messagesContainer.querySelector('#messageComposeRecoverySelect');
if (replyRecoverySelect) {
@@ -562,11 +589,6 @@ function attachMessagesEventListeners() {
cancelBtn.addEventListener('click', () => hideNewMessageModal());
}
- const sendNewBtn = _messagesContainer.querySelector('#sendNewMessage');
- if (sendNewBtn) {
- sendNewBtn.addEventListener('click', handleSendNewMessage);
- }
-
const newMessageRecoverySelect = _messagesContainer.querySelector('#newMessageRecoverySelect');
if (newMessageRecoverySelect) {
newMessageRecoverySelect.addEventListener('change', (event) => {
@@ -583,10 +605,6 @@ function attachMessagesEventListeners() {
});
}
- const newMessageContent = _messagesContainer.querySelector('#newMessageContent');
- if (newMessageContent) {
- newMessageContent.addEventListener('input', syncVisibleNewMessageDeliveryAttempt);
- }
}
/**
@@ -735,9 +753,10 @@ async function selectConversation(userId) {
if (renderedSelection) {
syncVisibleReplyDeliveryAttempt();
}
+ messageDictation.get(COMPOSE_SCOPE_REPLY)?.cancel();
const replyInput = _messagesContainer?.querySelector('#messageInput');
if (replyInput) {
- replyInput.value = '';
+ setConversationDraft(replyInput, '');
resizeCompactDraft(replyInput);
}
// Detach the in-memory attempt while changing scope without deleting the
@@ -1124,7 +1143,7 @@ function clearReplyContext({ removeQuote = false } = {}) {
showToast('The quote was edited. Remove it directly from your draft.', 'info');
return false;
}
- input.value = input.value.slice(_replyContextPrefix.length);
+ setConversationDraft(input, input.value.slice(_replyContextPrefix.length));
resizeCompactDraft(input);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
@@ -1145,7 +1164,7 @@ function selectReplyContext(messageId) {
const sender = message.relationships?.['#V#has_sender']?.[0] || '';
const content = message.concept_data?.content_fallback || '[Message text unavailable]';
_replyContextPrefix = `Replying to ${formatUserName(sender)} (${messageId}):\n${content.split(/\r?\n/u).map(line => `> ${line}`).join('\n')}\n\n`;
- input.value = _replyContextPrefix + input.value;
+ setConversationDraft(input, _replyContextPrefix + input.value);
const controls = document.createElement('div');
controls.className = 'message-reply-context-controls';
const note = document.createElement('span');
@@ -1358,13 +1377,9 @@ function renderComposePendingUi(scope) {
if (composer) composer.dataset.hasAttachments = String(imageItems(attachmentScope(scope)).length > 0);
}
button.disabled = pending || imagesBlocked(attachmentScope(scope)) || (scope === COMPOSE_SCOPE_REPLY && _exchange?.other_participant_ids?.length > 1);
- if (scope === COMPOSE_SCOPE_REPLY) {
- button.innerHTML = pending ? '…' : SEND_MESSAGE_ICON;
- button.setAttribute('aria-label', pending ? 'Sending message' : 'Send message');
- button.title = pending ? 'Sending message' : 'Send message';
- } else {
- button.textContent = pending ? 'Sending…' : 'Send';
- }
+ button.innerHTML = pending ? '…' : SEND_MESSAGE_ICON;
+ button.setAttribute('aria-label', pending ? 'Sending message' : 'Send message');
+ button.title = pending ? 'Sending message' : 'Send message';
button.setAttribute('aria-busy', pending ? 'true' : 'false');
if (scope === COMPOSE_SCOPE_NEW_MESSAGE) {
@@ -1792,10 +1807,6 @@ function replaceVisibleDeliveryAttempt(scope, {
});
}
-function resizeVisibleReplyComposer() {
- resizeCompactDraft(_messagesContainer?.querySelector('#messageInput'));
-}
-
function syncVisibleReplyDeliveryAttempt() {
const replyInput = _messagesContainer?.querySelector('#messageInput');
if (!replyInput) return;
@@ -1931,7 +1942,7 @@ function restoreNewMessageDeliveryAttemptForCurrentScope() {
_newMessageDeliveryAttempt = persistedAttempt;
recipientInput.value = persistedAttempt.recipient;
- contentInput.value = persistedAttempt.draft;
+ setConversationDraft(contentInput, persistedAttempt.draft);
modal.classList.remove('hidden');
setComposeFailureState(COMPOSE_SCOPE_NEW_MESSAGE, persistedAttempt.recoveryState);
return true;
@@ -1962,7 +1973,7 @@ function restoreReplyDeliveryAttemptForCurrentScope() {
}
_replyDeliveryAttempt = persistedAttempt;
- replyInput.value = persistedAttempt.draft;
+ setConversationDraft(replyInput, persistedAttempt.draft);
resizeCompactDraft(replyInput);
setComposeFailureState(COMPOSE_SCOPE_REPLY, persistedAttempt.recoveryState);
return true;
@@ -2334,7 +2345,19 @@ function buildSendPayload({
/**
* Handle sending a reply in the current conversation.
*/
+const finishingDictation = new Set();
+async function finishMessageDictation(scope) {
+ if (finishingDictation.has(scope)) return false;
+ const controller = messageDictation.get(scope);
+ if (!controller?.hasPendingInput()) return true;
+ finishingDictation.add(scope);
+ try { return await controller.finish(); }
+ finally { finishingDictation.delete(scope); }
+}
+
async function handleSendReply() {
+ if (messageDictation.get(COMPOSE_SCOPE_REPLY)?.hasPendingInput()
+ && !await finishMessageDictation(COMPOSE_SCOPE_REPLY)) return;
if (isComposePending(COMPOSE_SCOPE_REPLY)) {
return;
}
@@ -2349,7 +2372,7 @@ async function handleSendReply() {
if (!msgInput) return;
const visibleDraft = msgInput.value;
- const content = visibleDraft.trim();
+ const content = normaliseVontologyIdsForBackend(visibleDraft).trim();
if (!content && !imageItems(attachmentScope(COMPOSE_SCOPE_REPLY)).length) {
showToast('Please enter a message', 'warning');
return;
@@ -2369,6 +2392,7 @@ async function handleSendReply() {
const submittedConversationUserId = _currentConversationUserId;
const submittedGeneration = _exchangeGeneration;
const submittedAttachmentScope = attachmentScope(COMPOSE_SCOPE_REPLY);
+ messageHistoryCursors.delete(submittedAttachmentScope);
setComposePending(COMPOSE_SCOPE_REPLY, true);
try {
const result = await postJsonDetailed('/api/messages/', payload);
@@ -2381,10 +2405,10 @@ async function handleSendReply() {
const activeReplyInput = _messagesContainer?.querySelector('#messageInput');
if (
_currentConversationUserId === submittedConversationUserId
- && activeReplyInput?.value.trim() === content
+ && activeReplyInput?.value === visibleDraft
) {
clearReplyContext();
- activeReplyInput.value = '';
+ setConversationDraft(activeReplyInput, '');
resizeCompactDraft(activeReplyInput);
}
_exchangeDrafts.delete(exchangeScope());
@@ -2443,7 +2467,8 @@ function hideNewMessageModal({ force = false } = {}) {
const recipientInput = modal.querySelector('#newMessageRecipient');
const contentInput = modal.querySelector('#newMessageContent');
if (recipientInput) recipientInput.value = '';
- if (contentInput) contentInput.value = '';
+ messageDictation.get(COMPOSE_SCOPE_NEW_MESSAGE)?.cancel();
+ if (contentInput) setConversationDraft(contentInput, '');
if (_newMessageDeliveryAttempt) {
setDeliveryAttempt(COMPOSE_SCOPE_NEW_MESSAGE, null);
}
@@ -2454,6 +2479,8 @@ function hideNewMessageModal({ force = false } = {}) {
* Handle sending a new message from the modal.
*/
async function handleSendNewMessage() {
+ if (messageDictation.get(COMPOSE_SCOPE_NEW_MESSAGE)?.hasPendingInput()
+ && !await finishMessageDictation(COMPOSE_SCOPE_NEW_MESSAGE)) return;
if (isComposePending(COMPOSE_SCOPE_NEW_MESSAGE)) {
return;
}
@@ -2465,8 +2492,8 @@ async function handleSendNewMessage() {
const visibleRecipient = recipientInput.value;
const visibleDraft = contentInput.value;
- const recipient = visibleRecipient.trim();
- const content = visibleDraft.trim();
+ const recipient = normaliseVontologyIdsForBackend(visibleRecipient).trim();
+ const content = normaliseVontologyIdsForBackend(visibleDraft).trim();
if (!recipient) {
showToast('Please enter a recipient', 'warning');
diff --git a/src/frontend/web/von_interface/static/js/dictation.js b/src/frontend/web/von_interface/static/js/dictation.js
index 7a30e7bc..1ae2e18e 100644
--- a/src/frontend/web/von_interface/static/js/dictation.js
+++ b/src/frontend/web/von_interface/static/js/dictation.js
@@ -72,6 +72,7 @@ export function createDictationController({ input, button, status, cancelButton,
: voiceActive
? 'Voice conversation is active. Click to switch to dictation. Shift-click or long-press to end voice.'
: 'Click to dictate into your draft. Shift-click or long-press to start a voice conversation: speech is sent automatically and Von replies aloud.';
+ if (!onAlternateClick) button.title = next === 'recording' ? 'Finish dictation and add it to your draft' : 'Dictate into your draft';
button.classList.toggle('dictation-recording', next === 'recording');
button.classList.toggle('active-voice', voiceActive);
button.disabled = next === 'transcribing' || next === 'requesting';
diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css
index 69df7196..07a44de6 100644
--- a/src/frontend/web/von_interface/static/styles.css
+++ b/src/frontend/web/von_interface/static/styles.css
@@ -11835,7 +11835,7 @@ body[data-active-tab="settingsTab"] .tab-content-area {
min-height: 44px;
}
-.chat-composer .composer-icon-button,
+.chat-composer-primary-actions .composer-icon-button,
#sendMessageBtn.composer-icon-button {
display: inline-flex;
align-items: center;
@@ -11982,7 +11982,7 @@ body[data-active-tab="settingsTab"] .tab-content-area {
}
.chat-composer>.prompt-input-wrapper,
- .chat-composer #promptInput {
+ .chat-composer :is(#promptInput, .conversation-draft-input) {
grid-column: 1;
grid-row: 1;
min-width: 0;
@@ -18651,12 +18651,12 @@ body.settings-body {
padding: 6px;
}
.chat-composer > .prompt-input-wrapper,
- .chat-composer > #promptInput { grid-column: 2; grid-row: 1; min-width: 0; }
- .chat-composer #promptInput { margin: 0; min-height: 44px; padding: 10px 8px; }
+ .chat-composer > :is(#promptInput, .conversation-draft-input) { grid-column: 2; grid-row: 1; min-width: 0; }
+ .chat-composer :is(#promptInput, .conversation-draft-input) { margin: 0; min-height: 44px; padding: 10px 8px; }
.chat-composer-actions, .chat-composer-primary-actions { display: contents; }
.chat-composer-more-actions { display: block; position: static; grid-column: 1 / -1; grid-row: 2; }
- .chat-composer #sendButton { grid-column: 3; grid-row: 1; width: 44px; min-width: 44px; }
- .chat-composer #sendButton[data-has-draft="false"]:not([aria-busy="true"]) { display: none; }
+ .chat-composer :is(#sendButton, .conversation-send-button) { grid-column: 3; grid-row: 1; width: 44px; min-width: 44px; }
+ .chat-composer :is(#sendButton, .conversation-send-button)[data-has-draft="false"]:not([aria-busy="true"]) { display: none; }
.chat-composer .composer-icon-button svg,
#sendMessageBtn.composer-icon-button svg { width: 20px; height: 20px; }
.chat-composer-more-actions > summary {
@@ -18683,8 +18683,8 @@ body.settings-body {
.compact-speech-help, #imageAttachmentHelp { display: block; flex-basis: 100%; font-size: 12px; margin: 0; }
.chat-composer-more-actions .button-row > button,
.chat-composer-more-actions .button-row > select { min-height: 44px; margin: 0; }
- .chat-composer-more-actions #dictateButton { width: auto; flex-basis: auto; padding: 0 12px; border-radius: 6px; gap: 6px; }
- .chat-composer-more-actions #dictateButton .button-label {
+ .chat-composer-more-actions :is(#dictateButton, .conversation-dictate-button) { width: auto; flex-basis: auto; padding: 0 12px; border-radius: 6px; gap: 6px; }
+ .chat-composer-more-actions :is(#dictateButton, .conversation-dictate-button) .button-label {
position: static; width: auto; height: auto; margin: 0; overflow: visible; clip: auto; white-space: normal;
}
.compact-composer-active:has(button:not([hidden])) {
@@ -18977,9 +18977,9 @@ html[data-footer-uptime="false"][data-footer-runtimecost="false"][data-footer-bu
.conversation-image-draft .conversation-image-gallery img { max-height: 140px !important; }
@media (max-width: 800px), (max-width: 1024px) and (pointer: coarse) {
.chat-composer { grid-template-columns: 44px 44px minmax(0, 1fr) auto; }
- .chat-composer #attachImageButton { grid-column: 2; grid-row: 1; width: 44px; min-width: 44px; }
- .chat-composer > .prompt-input-wrapper, .chat-composer > #promptInput { grid-column: 3; }
- .chat-composer #sendButton { grid-column: 4; }
+ .chat-composer :is(#attachImageButton, .conversation-attachment-button) { grid-column: 2; grid-row: 1; width: 44px; min-width: 44px; }
+ .chat-composer > .prompt-input-wrapper, .chat-composer > :is(#promptInput, .conversation-draft-input) { grid-column: 3; }
+ .chat-composer :is(#sendButton, .conversation-send-button) { grid-column: 4; }
}
/* Desktop navigation belongs to the transcript and never changes composer height.
@@ -19139,3 +19139,11 @@ html[data-footer-uptime="false"][data-footer-runtimecost="false"][data-footer-bu
.search-content-types-menu legend { font-size: 13px; background: #fff; }
.search-content-types-menu label { display: flex; align-items: center; gap: 8px; min-height: 36px; cursor: pointer; }
.search-content-types-menu input { margin: 0; width: auto; }
+
+/* Message carriers reuse the Von composer within their existing transcript/modal. */
+.message-draft-shell.chat-composer {
+ position: relative; inset: auto; margin: 0; width: 100%; box-sizing: border-box;
+}
+.message-draft-shell .prompt-input { width: 100%; box-sizing: border-box; }
+.message-draft-shell [data-draft-recovery][hidden] { display: none; }
+.message-draft-shell .composer-options-section select { max-width: 100%; min-height: 44px; }
diff --git a/tests/browser/messageComposer.cjs b/tests/browser/messageComposer.cjs
new file mode 100644
index 00000000..cae8c8c8
--- /dev/null
+++ b/tests/browser/messageComposer.cjs
@@ -0,0 +1,100 @@
+// Fixture-backed browser check of the production Messages module and CSS.
+// No live accounts, backend writes or model calls.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const http = require('node:http');
+const path = require('node:path');
+const { chromium, expect } = require('@playwright/test');
+
+const root = path.resolve(__dirname, '../../src/frontend/web/von_interface');
+const markup = '';
+const evidence = process.argv[2];
+if (evidence) fs.mkdirSync(evidence, { recursive: true });
+const server = http.createServer((req, res) => {
+ if (req.url === '/') {
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.end(`
+ ${markup}`);
+ return;
+ }
+ const file = path.resolve(root, `.${req.url}`);
+ if (!file.startsWith(`${root}/static/`) || !fs.existsSync(file)) {
+ res.writeHead(404).end();
+ return;
+ }
+ res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : 'text/javascript');
+ res.end(fs.readFileSync(file));
+});
+
+(async () => {
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+ const browser = await chromium.launch({ headless: true });
+ try {
+ for (const width of [1280, 390]) {
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
+ let sent;
+ let messages = [{ concept_id: '#V#source', concept_data: { content_fallback: 'Please review the experiment results.' }, relationships: { '#V#has_sender': ['#V#bob'] } }];
+ await page.route('**/api/**', async route => {
+ const url = route.request().url();
+ let data = {};
+ if (url.endsWith('/messages/') && route.request().method() === 'POST') {
+ sent = route.request().postDataJSON();
+ messages = [{ concept_id: '#V#reply', concept_data: { content_fallback: sent.content }, relationships: { '#V#has_sender': ['#V#alice'] } }];
+ data = { success: true };
+ } else if (url.includes('/messages/exchange')) data = { messages, current_user_id: '#V#alice' };
+ else if (url.includes('/vontology/search')) data = { results: [{ id: '#V#research', name: 'Research', kind: 'type' }] };
+ else if (url.includes('/messages/threads')) data = { threads: [] };
+ await route.fulfill({ json: data });
+ });
+ await page.goto(`http://127.0.0.1:${server.address().port}`);
+ await page.evaluate(async () => {
+ const panel = await import('/static/js/components/messagePanel.js');
+ await panel.openMessageExchange({ session_id: 'existing', viewer_id: '#V#alice', session_name: 'Bob',
+ participant_ids: ['#V#alice', '#V#bob'], other_participant_ids: ['#V#bob'], organisation_concept_id: '#V#lab' });
+ });
+ const input = page.locator('#messageInput');
+ await input.fill('#V#res');
+ await expect(page.locator('.concept-autocomplete-item').first()).toBeVisible();
+ await input.press('ArrowDown');
+ await input.press('Enter');
+ assert.equal(sent, undefined, 'Autocomplete Enter must not send');
+ await expect(input).toHaveValue(/research/);
+ await expect(page.locator('#messageComposeArea .prompt-vontology-cartouche').first()).toBeVisible();
+ if (width > 800) await expect(page.locator('#messageComposeArea button[aria-label="Dictate"]')).toBeVisible();
+ await expect(page.locator('#messageComposeArea button[aria-label="Attach files"]')).toBeVisible();
+ const more = await page.locator('#messageComposeArea summary').boundingBox();
+ const composer = await page.locator('#messageComposeArea').boundingBox();
+ assert.ok(more.y >= composer.y && more.y + more.height <= composer.y + composer.height, 'More actions stays inside the composer');
+ await page.locator('#messageComposeArea summary').click();
+ await expect(page.locator('#messageComposeArea select').last()).toBeVisible();
+ await expect(page.locator('#messageComposeArea button[aria-label="Dictate"]')).toBeVisible();
+ await page.locator('#messageComposeArea summary').press('Escape');
+ const dimensions = await input.evaluate(el => ({ width: el.getBoundingClientRect().width, page: document.documentElement.scrollWidth, viewport: innerWidth }));
+ assert.ok(dimensions.width > 150, JSON.stringify(dimensions));
+ assert.ok(dimensions.page <= dimensions.viewport, JSON.stringify(dimensions));
+ if (evidence) await page.screenshot({ path: path.join(evidence, `message-composer-${width}.png`) });
+ if (width < 800) {
+ await input.press('Enter');
+ assert.equal(sent, undefined, 'Mobile Enter inserts a newline');
+ }
+ await page.locator('#sendMessageBtn').click();
+ await expect(input).toHaveValue('');
+ assert.equal(sent.content, '#V#research');
+ assert.deepEqual(sent.recipient_ids, ['#V#bob']);
+ assert.equal(sent.organisation_concept_id, '#V#lab');
+ // New-message content uses precisely the same editor and controls.
+ await page.locator('#newMessageBtn').click();
+ await page.locator('#newMessageRecipient').fill('#V#bob');
+ const draft = page.locator('#newMessageContent');
+ await draft.fill('#V#res');
+ await expect(page.locator('.concept-autocomplete-item').first()).toBeVisible();
+ await draft.press('ArrowDown'); await draft.press('Enter');
+ await expect(page.locator('.message-modal-body .prompt-vontology-cartouche').first()).toBeVisible();
+ await page.locator('#sendNewMessage').click();
+ await expect(page.locator('#newMessageModal')).toHaveClass(/hidden/);
+ assert.equal(sent.content, '#V#research');
+ await page.close();
+ }
+ console.log('PASS: production Messaging reply/new-message autocomplete, cartouches, controls, normalised sends, keyboard and layout at 1280 and 390px (fixture APIs).');
+ } finally { await browser.close(); server.close(); }
+})().catch(error => { console.error(error); server.close(); process.exitCode = 1; });
diff --git a/tests/frontend/conversationDraft.test.js b/tests/frontend/conversationDraft.test.js
new file mode 100644
index 00000000..98c5fe15
--- /dev/null
+++ b/tests/frontend/conversationDraft.test.js
@@ -0,0 +1,67 @@
+/** @jest-environment jsdom */
+import { initialiseConversationDraft, navigateConversationDraftHistory, normaliseVontologyIdsForBackend, setConversationDraft } from '../../src/frontend/web/von_interface/static/js/components/conversationDraft.js';
+import { closeAutocomplete } from '../../src/frontend/web/von_interface/static/js/components/conceptAutocomplete.js';
+
+const tick = () => new Promise(resolve => setTimeout(resolve, 320));
+
+describe('shared conversation draft', () => {
+ let input, send, submit, changed;
+ const originalFetch = global.fetch;
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ input = document.getElementById('draft'); send = document.getElementById('send');
+ submit = jest.fn(); changed = jest.fn();
+ window.matchMedia = jest.fn(() => ({ matches: false }));
+ global.fetch = jest.fn(async () => ({ ok: true, json: async () => ({ results: [{ id: '#V#research', name: 'Research', kind: 'type' }] }) }));
+ initialiseConversationDraft({ input, sendButton: send, onSubmit: submit, onInput: changed });
+ });
+ afterEach(() => { closeAutocomplete(); global.fetch = originalFetch; });
+
+ test('concept selection consumes Enter before submission and normalises for delivery', async () => {
+ input.focus(); input.value = '#V#res'; input.setSelectionRange(6, 6);
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ await tick();
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }));
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }));
+ expect(submit).not.toHaveBeenCalled();
+ expect(normaliseVontologyIdsForBackend(input.value).trim()).toBe('#V#research');
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }));
+ expect(submit).toHaveBeenCalledTimes(1);
+ });
+
+ test('newly focused draft search survives the previous field blur timer', async () => {
+ const { initializeConceptAutocomplete } = require('../../src/frontend/web/von_interface/static/js/components/conceptAutocomplete.js');
+ const recipient = document.getElementById('recipient');
+ initializeConceptAutocomplete(recipient);
+ recipient.focus(); input.focus();
+ input.value = '#V#res'; input.setSelectionRange(6, 6);
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ await tick();
+ expect(document.querySelector('.concept-autocomplete-dropdown').style.display).not.toBe('none');
+ });
+
+ test('restoring draft updates the editor without invalidating delivery recovery', () => {
+ setConversationDraft(input, 'Retained draft');
+ expect(input.value).toBe('Retained draft');
+ expect(input.dataset.hasDraft).toBe('true');
+ expect(changed).not.toHaveBeenCalled();
+ });
+
+ test('mobile, composition and Shift+Enter never submit', () => {
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', isComposing: true, cancelable: true }));
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, cancelable: true }));
+ window.matchMedia.mockReturnValue({ matches: true });
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }));
+ expect(submit).not.toHaveBeenCalled();
+ send.click(); expect(submit).toHaveBeenCalledTimes(1);
+ });
+
+ test('history recall preserves edits and returns to an empty draft', () => {
+ const event = key => new KeyboardEvent('keydown', { key, cancelable: true });
+ expect(navigateConversationDraftHistory(event('ArrowUp'), input, ['First', 'Last'], 2)).toEqual({ cursor: 1, value: 'Last' });
+ input.value = 'Last';
+ expect(navigateConversationDraftHistory(event('ArrowDown'), input, ['First', 'Last'], 1)).toEqual({ cursor: 2, value: '' });
+ input.value = 'My edit';
+ expect(navigateConversationDraftHistory(event('ArrowUp'), input, ['First', 'Last'], 1)).toBeNull();
+ });
+});
diff --git a/tests/frontend/messageComposerDictation.test.js b/tests/frontend/messageComposerDictation.test.js
new file mode 100644
index 00000000..cdc1a572
--- /dev/null
+++ b/tests/frontend/messageComposerDictation.test.js
@@ -0,0 +1,67 @@
+/** @jest-environment jsdom */
+jest.mock('../../src/frontend/web/von_interface/static/js/apiService.js', () => ({
+ getJson: jest.fn(), postJson: jest.fn(), postJsonDetailed: jest.fn(), getWindowSessionId: () => 'fixture', WINDOW_SESSION_HEADER: 'X-Von-Window-Session'
+}));
+jest.mock('../../src/frontend/web/von_interface/static/js/dictation.js', () => ({ createDictationController: jest.fn() }));
+
+const { createDictationController } = require('../../src/frontend/web/von_interface/static/js/dictation.js');
+const { getJson, postJsonDetailed } = require('../../src/frontend/web/von_interface/static/js/apiService.js');
+const { showMessagesTab, resetMessagePanelContext, initializeMessagePanel } = require('../../src/frontend/web/von_interface/static/js/components/messagePanel.js');
+const tick = () => new Promise(resolve => setTimeout(resolve, 0));
+let controllers;
+
+beforeEach(async () => {
+ document.body.innerHTML = '';
+ sessionStorage.clear();
+ controllers = [];
+ createDictationController.mockImplementation(options => {
+ const controller = { options, pending: false, hasPendingInput: () => controller.pending,
+ finish: jest.fn(async () => true), cancel: jest.fn(), dispose: jest.fn() };
+ controllers.push(controller); return controller;
+ });
+ getJson.mockImplementation(async url => {
+ if (url.includes('/threads')) return { threads: [] };
+ return { unread_count: 0, messages: [], current_user_id: '#V#alice' };
+ });
+ postJsonDetailed.mockReset();
+ postJsonDetailed.mockResolvedValue({ data: { success: true } });
+ initializeMessagePanel();
+ await showMessagesTab();
+ document.getElementById('newMessageBtn').click();
+ document.getElementById('newMessageRecipient').value = '#V#bob';
+});
+
+test('Send waits for dictation and sends the resulting draft once', async () => {
+ const controller = controllers[1];
+ controller.pending = true;
+ let finish;
+ controller.finish.mockImplementation(() => new Promise(resolve => { finish = resolve; }));
+ document.getElementById('sendNewMessage').click();
+ document.getElementById('sendNewMessage').click();
+ expect(postJsonDetailed).not.toHaveBeenCalled();
+ expect(controller.finish).toHaveBeenCalledTimes(1);
+ controller.options.setValue('Dictated #V\u200b#research');
+ controller.pending = false; finish(true);
+ await tick();
+ expect(postJsonDetailed).toHaveBeenCalledTimes(1);
+ expect(postJsonDetailed.mock.calls[0][1]).toMatchObject({ content: 'Dictated #V#research', recipient_ids: ['#V#bob'] });
+});
+
+test('failed dictation retains draft and does not send', async () => {
+ const controller = controllers[1];
+ controller.pending = true; controller.finish.mockResolvedValue(false);
+ document.getElementById('newMessageContent').value = 'Retain this';
+ document.getElementById('sendNewMessage').click();
+ await tick();
+ expect(postJsonDetailed).not.toHaveBeenCalled();
+ expect(document.getElementById('newMessageContent').value).toBe('Retain this');
+});
+
+test('changing recipient invalidates capture context; resetting cancels capture', () => {
+ const controller = controllers[1];
+ const before = controller.options.getContext().key;
+ document.getElementById('newMessageRecipient').value = '#V#charlie';
+ expect(controller.options.getContext().key).not.toBe(before);
+ resetMessagePanelContext();
+ for (const entry of controllers) expect(entry.cancel).toHaveBeenCalled();
+});
diff --git a/tests/frontend/messagePanelSendFailure.test.js b/tests/frontend/messagePanelSendFailure.test.js
index c6b080b7..ae310f2a 100644
--- a/tests/frontend/messagePanelSendFailure.test.js
+++ b/tests/frontend/messagePanelSendFailure.test.js
@@ -466,7 +466,7 @@ describe('message panel send failure recovery', () => {
expect(postJsonDetailed).toHaveBeenCalledTimes(1);
expect(sendButton.disabled).toBe(true);
- expect(sendButton.textContent).toBe('Sending…');
+ expect(sendButton.getAttribute('aria-label')).toBe('Sending message');
expect(recipientInput.disabled).toBe(true);
expect(contentInput.disabled).toBe(true);
sendButton.click();
@@ -500,7 +500,7 @@ describe('message panel send failure recovery', () => {
expect(recipientInput.value).toBe('user_alice');
expect(contentInput.value).toBe('Changed draft');
expect(sendButton.disabled).toBe(false);
- expect(sendButton.textContent).toBe('Send');
+ expect(sendButton.getAttribute('aria-label')).toBe('Send message');
});
test('new-message retry restores its exact recipient, draft, and key across render and module reload', async () => {