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 +
+

Voice & dictation

+

Von dictation sends audio and draft context to OpenAI. Von does not save the audio. Browser recognition uses your browser’s speech service.

+
`; + 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 () => {