diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index 38426b92e..690bc1707 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -1,3 +1,4 @@ +import { setButtonLabel } from './utils/buttonLabel.js'; import { createConversationTray } from './components/conversationTray.js'; import { CONVERSATION_LAYOUT_KEY, CONVERSATION_LAYOUT_KEYS, CONVERSATION_NARROW_QUERY, loadConversationLayoutPreferences, normaliseConversationLayout, saveConversationLayoutPreference } from './utils/conversationLayoutPreferences.js'; import { createVoiceConversation } from './voiceConversation.js'; @@ -6700,22 +6701,22 @@ function updateSendButtonForCurrentChatState() { chatCreationInFlight || queueSubmissionInFlight || conceptQaBusy ? 'true' : 'false' ); if (chatCreationInFlight) { - sendButton.textContent = 'Creating conversation…'; + setButtonLabel(sendButton, 'Creating conversation…'); sendButton.title = 'Waiting for the new conversation to be ready.'; return; } if (readOnly) { - sendButton.textContent = 'Read-only import'; + setButtonLabel(sendButton, 'Read-only import'); sendButton.title = 'Continue this imported snapshot before sending a new message.'; return; } if (conceptQaSession) { if (conceptQaTerminal) { - sendButton.textContent = `Q&A ${conceptQaSession.lifecycle}`; + setButtonLabel(sendButton, `Q&A ${conceptQaSession.lifecycle}`); } else if (conceptQaAwaitingInitialQuestion) { - sendButton.textContent = 'Retry question first'; + setButtonLabel(sendButton, 'Retry question first'); } else { - sendButton.textContent = conceptQaBusy ? 'Saving Q&A…' : 'Submit answer'; + setButtonLabel(sendButton, conceptQaBusy ? 'Saving Q&A…' : 'Submit answer'); } if (conceptQaAwaitingInitialQuestion) { sendButton.title = 'Return to the concept card and retry the initial question before answering.'; @@ -6726,9 +6727,9 @@ function updateSendButtonForCurrentChatState() { : (conceptQaBusy ? 'Saving this turn and its representation receipts.' : 'Submit this answer to concept Q&A.'); return; } - sendButton.textContent = queueSubmissionInFlight + setButtonLabel(sendButton, queueSubmissionInFlight ? 'Queueing…' - : (sessionBusy ? 'Queue Prompt' : 'Send Prompt'); + : (sessionBusy ? 'Queue Prompt' : 'Send Prompt')); if (queueSubmissionInFlight) { sendButton.title = 'Saving this prompt to the conversation queue.'; } else if (imagePreparationBlocked && !uploadInFlight) { @@ -6736,7 +6737,7 @@ function updateSendButtonForCurrentChatState() { } else if (uploadInFlight) { sendButton.title = ATTACHMENT_UPLOAD_SEND_BLOCK_MESSAGE; } else { - sendButton.removeAttribute('title'); + sendButton.title = sessionBusy ? 'Queue Prompt' : 'Send Prompt'; } } @@ -31036,7 +31037,8 @@ function ensureScrollToEndButton(scrollableField = null) { return null; } - const controlHost = targetField.closest('.content-wrapper') || targetField.parentElement; + const wrapper = targetField.closest('.content-wrapper') || targetField.parentElement; + const controlHost = wrapper?.querySelector('.chat-composer') || wrapper; if (!(controlHost instanceof HTMLElement)) { return null; } @@ -34290,6 +34292,7 @@ export function initializeChatTab() { getContext: getDictationContext, fetchImpl: (url, options = {}) => fetch(url, { ...options, headers: buildChatFetchHeaders(options.headers) }), setValue: value => setPromptComposerValue(value, { promptInput }), + onAlternateClick: () => { void voiceConversation?.start(); }, onStart: () => { voiceConversation?.end(); stopSpeaking(); clearActiveTtsUi(); } }); } @@ -34298,6 +34301,7 @@ export function initializeChatTab() { const voiceButton = document.getElementById('voiceConversationButton'); if (voiceButton) voiceConversation = createVoiceConversation({ button: voiceButton, status: document.getElementById('voiceConversationStatus'), + onActiveChange: active => dictationController?.setVoiceActive(active), getContext: getDictationContext, fetchImpl: (url, options = {}) => fetch(url, { ...options, headers: buildChatFetchHeaders(options.headers) }), onStart: async () => { diff --git a/src/frontend/web/von_interface/static/js/dictation.js b/src/frontend/web/von_interface/static/js/dictation.js index 7ba2f50f2..f73e5edf1 100644 --- a/src/frontend/web/von_interface/static/js/dictation.js +++ b/src/frontend/web/von_interface/static/js/dictation.js @@ -1,3 +1,4 @@ +import { setButtonLabel } from './utils/buttonLabel.js'; import { createStreamingInput } from './streamingSpeech.js'; import { createSpeechReporter } from './clientContext.js'; import { isSpeechRecognitionSupported, startSpeechRecognition, stopSpeaking } from './speech.js'; @@ -34,7 +35,7 @@ function microphoneError(error) { // One controller per composer. A capture belongs to the conversation and actor // that started it. Cancelling fences permission, recognition and fetch callbacks. export function createDictationController({ input, button, status, cancelButton, retryButton, - engineSelect, getContext, setValue, onStart = stopSpeaking, fetchImpl = (...args) => fetch(...args), + engineSelect, getContext, setValue, onStart = stopSpeaking, onAlternateClick = null, fetchImpl = (...args) => fetch(...args), root = globalThis }) { let current = null; let capability = null; @@ -42,14 +43,38 @@ export function createDictationController({ input, button, status, cancelButton, let disposed = false; const attemptIds = []; let engineChosen = false; + let voiceActive = false; + let pressTimer = null, touchOrigin = null, suppressClick = false; + const clearPress = () => { clearTimeout(pressTimer); pressTimer = null; touchOrigin = null; }; + const pointerDown = event => { + clearPress(); suppressClick = false; + if (event.pointerType !== 'touch' || !onAlternateClick || button.disabled) return; + touchOrigin = { x: event.clientX, y: event.clientY }; + pressTimer = setTimeout(() => { + pressTimer = null; + suppressClick = true; + onAlternateClick(); + }, 600); + }; + const pointerMove = event => { + if (touchOrigin && Math.hypot(event.clientX - touchOrigin.x, event.clientY - touchOrigin.y) > 10) clearPress(); + }; + const contextMenu = event => { if (touchOrigin || suppressClick) event.preventDefault(); }; function render(next, message = '') { state = next; current?.reporter?.event('state', { state: next }); const busy = ['requesting', 'recording', 'transcribing'].includes(next); - button.textContent = next === 'recording' ? 'Finish dictation' : next === 'transcribing' ? 'Transcribing…' : next === 'requesting' ? 'Opening microphone…' : 'Dictate'; + setButtonLabel(button, next === 'recording' ? 'Finish dictation' : next === 'transcribing' ? 'Transcribing…' : next === 'requesting' ? 'Opening microphone…' : voiceActive ? 'Switch to dictation' : 'Dictate'); + button.title = next === 'recording' + ? 'Click to finish dictation and add it to your draft. Shift-click or long-press to start a voice conversation.' + : 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.'; + button.classList.toggle('dictation-recording', next === 'recording'); + button.classList.toggle('active-voice', voiceActive); button.disabled = next === 'transcribing' || next === 'requesting'; - button.setAttribute('aria-pressed', String(next === 'recording')); + button.setAttribute('aria-pressed', String(next === 'recording' || voiceActive)); button.classList.toggle('active-dictation', busy); status.textContent = message; cancelButton.hidden = !current; @@ -67,6 +92,7 @@ export function createDictationController({ input, button, status, cancelButton, } function cancel() { + clearPress(); const capture = current; current = null; if (capture) { @@ -249,7 +275,11 @@ export function createDictationController({ input, button, status, cancelButton, } } - const click = () => { if (state === 'recording') void finish(); else void start(); }; + const click = event => { + if (suppressClick) { suppressClick = false; event.preventDefault(); return; } + if (event.shiftKey && onAlternateClick) { onAlternateClick(); return; } + if (state === 'recording') void finish(); else void start(); + }; const retry = () => { if (current?.blob) { current.completion = new Promise(resolve => { current.resolve = resolve; }); void transcribe(current); @@ -258,6 +288,12 @@ export function createDictationController({ input, button, status, cancelButton, const chooseEngine = () => { engineChosen = true; }; engineSelect.addEventListener('change', chooseEngine); button.addEventListener('click', click); + button.addEventListener('pointerdown', pointerDown); + button.addEventListener('pointermove', pointerMove); + button.addEventListener('pointerup', clearPress); + button.addEventListener('pointercancel', clearPress); + button.addEventListener('pointerleave', clearPress); + button.addEventListener('contextmenu', contextMenu); cancelButton.addEventListener('click', cancel); retryButton.addEventListener('click', retry); root.addEventListener?.('pagehide', cancel); @@ -266,10 +302,17 @@ export function createDictationController({ input, button, status, cancelButton, root.document?.addEventListener('visibilitychange', visibility); render('idle'); void refreshCapabilities(); - return { cancel, finish, start, refreshCapabilities, getAttemptIds: () => [...attemptIds], clearAttemptIds: () => { attemptIds.length = 0; }, + return { cancel, finish, start, refreshCapabilities, + setVoiceActive: active => { voiceActive = active; render(state, status.textContent); }, getAttemptIds: () => [...attemptIds], clearAttemptIds: () => { attemptIds.length = 0; }, getState: () => state, hasPendingInput: () => !!current, - dispose: () => { cancel(); disposed = true; + dispose: () => { clearPress(); cancel(); disposed = true; engineSelect.removeEventListener('change', chooseEngine); + button.removeEventListener('pointerdown', pointerDown); + button.removeEventListener('pointermove', pointerMove); + button.removeEventListener('pointerup', clearPress); + button.removeEventListener('pointercancel', clearPress); + button.removeEventListener('pointerleave', clearPress); + button.removeEventListener('contextmenu', contextMenu); button.removeEventListener('click', click); cancelButton.removeEventListener('click', cancel); retryButton.removeEventListener('click', retry); root.removeEventListener?.('pagehide', cancel); root.removeEventListener?.('focus', refreshCapabilities); diff --git a/src/frontend/web/von_interface/static/js/test/chatTab.test.js b/src/frontend/web/von_interface/static/js/test/chatTab.test.js index 7bc86957b..03e0a421c 100644 --- a/src/frontend/web/von_interface/static/js/test/chatTab.test.js +++ b/src/frontend/web/von_interface/static/js/test/chatTab.test.js @@ -10926,11 +10926,13 @@ describe('chat session composer state', () => { }); await expect(__testOnly_refreshChatSessionTabs()).resolves.toBeUndefined(); + document.getElementById('sendButton').innerHTML = ''; document.querySelector('.chat-session-tab-new').click(); const sendButton = document.getElementById('sendButton'); expect(sendButton.disabled).toBe(true); expect(sendButton.textContent).toBe('Creating conversation…'); + expect(sendButton.querySelector('svg')).not.toBeNull(); expect(sendButton.getAttribute('aria-busy')).toBe('true'); const sendPromise = sendMessage(); @@ -12716,7 +12718,7 @@ describe('scroll to latest message affordance', () => {