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 = 'Send Prompt'; 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', () => {
-
+
`; @@ -12733,6 +12735,7 @@ describe('scroll to latest message affordance', () => { __testOnly_updateScrollToEndButtonVisibility(scrollableField); const button = __testOnly_ensureScrollToEndButton(scrollableField); expect(button).toBeTruthy(); + expect(button.parentElement).toBe(document.querySelector('.chat-composer')); expect(button.classList.contains('visible')).toBe(true); expect(button.getAttribute('aria-hidden')).toBe('false'); diff --git a/src/frontend/web/von_interface/static/js/utils/buttonLabel.js b/src/frontend/web/von_interface/static/js/utils/buttonLabel.js new file mode 100644 index 000000000..ac331daf0 --- /dev/null +++ b/src/frontend/web/von_interface/static/js/utils/buttonLabel.js @@ -0,0 +1,5 @@ +// Preserve decorative icons while updating the accessible action/state label. +export function setButtonLabel(button, label) { + const target = button.querySelector('.button-label') || button; + target.textContent = label; +} diff --git a/src/frontend/web/von_interface/static/js/voiceConversation.js b/src/frontend/web/von_interface/static/js/voiceConversation.js index 69cbe3cb7..cb14c59d8 100644 --- a/src/frontend/web/von_interface/static/js/voiceConversation.js +++ b/src/frontend/web/von_interface/static/js/voiceConversation.js @@ -1,7 +1,7 @@ import { createStreamingInput, createSpeechPlayback } from './streamingSpeech.js'; import { createSpeechReporter } from './clientContext.js'; -export function createVoiceConversation({ button, status, getContext, onSubmit, onStart = () => {}, +export function createVoiceConversation({ button, status, getContext, onSubmit, onStart = () => {}, onActiveChange = () => {}, fetchImpl = (...args) => fetch(...args), root = globalThis }) { let session = null, disposed = false, starting = false, generation = 0; const playback = createSpeechPlayback({ fetchImpl, root, @@ -16,12 +16,14 @@ export function createVoiceConversation({ button, status, getContext, onSubmit, old?.reporter.event('ended', { reason: 'ended' }); old?.input.cancel(); playback.stop('ended'); session = null; + onActiveChange(false); button.textContent = 'Start voice'; button.setAttribute('aria-pressed', 'false'); status.textContent = message; } async function start() { if (session || starting) return end(); starting = true; const token = ++generation; + onActiveChange(true); button.textContent = 'End voice'; button.setAttribute('aria-pressed', 'true'); // Resume audio in the initiating gesture, before permission/network awaits. try { await playback.unlock(); } catch (_) { if (generation === token) end('This browser could not open audio playback.'); return; } diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 774e8266c..b77bec247 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -5574,6 +5574,13 @@ h1 { } } +/* Follow the composer's actual top edge, including resized drafts and open actions. */ +.chat-composer > .chat-scroll-to-end-btn { + position: absolute; + right: 10px; + bottom: calc(100% + 10px); +} + /* Dropdown */ select { background-color: #fff; @@ -11599,12 +11606,51 @@ body[data-active-tab="settingsTab"] .tab-content-area { display: none; } -#dictateButton, #cancelDictationButton, #retryDictationButton { min-height: 44px; } +.chat-composer .composer-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 44px; + width: 44px; + height: 44px; + min-height: 44px; + padding: 0; + margin: 0; + border-radius: 50%; +} + +.composer-icon-button svg { + width: 24px; + height: 24px; + pointer-events: none; +} + +.composer-icon-button .dictation-stop-icon, +.composer-icon-button.dictation-recording .microphone-icon { + display: none; +} + +.composer-icon-button.dictation-recording .dictation-stop-icon { + display: block; +} + +.composer-icon-button.active-dictation, +.composer-icon-button.active-voice { + background: #b91c1c; + color: #fff; + box-shadow: 0 0 0 3px #fecaca; +} + +.composer-icon-button:focus-visible { + outline: 3px solid #2563eb; + outline-offset: 3px; +} + #dictationStatus { margin: 8px 0 0; overflow-wrap: anywhere; @@ -11678,7 +11724,6 @@ body[data-active-tab="settingsTab"] .tab-content-area { /* Narrow screens: allow wrap but maintain spacing */ @media (max-width: 520px) { - #sendButton, #resetButton { margin-bottom: 8px; } diff --git a/src/frontend/web/von_interface/templates/chat_tab.html b/src/frontend/web/von_interface/templates/chat_tab.html index d40e8cbee..e20ad1d8b 100644 --- a/src/frontend/web/von_interface/templates/chat_tab.html +++ b/src/frontend/web/von_interface/templates/chat_tab.html @@ -254,11 +254,16 @@

- - - + + Click to dictate into your draft. Shift-click or long-press to start or end a voice conversation: speech is sent automatically and Von replies aloud. Voice is also available in More actions.
@@ -266,6 +271,7 @@

More actions
+

'; + document.body.innerHTML = '

'; input = document.querySelector('textarea'); input.value = 'Existing draft'; input.selectionStart = input.selectionEnd = input.value.length; context = { key: 'actor:conversation', text: 'Vontology and Wikidata', vocabulary: ['Vontology', 'Wikidata'], language: 'en-NZ' }; @@ -28,11 +28,57 @@ describe('recorded dictation lifecycle', () => { fetchImpl = jest.fn(async url => url.endsWith('capabilities') ? { ok: true, json: async () => ({ transcription: { available: true, max_audio_bytes: 24 * 1024 * 1024 } }) } : pending.promise); + onAlternateClick = jest.fn(); buttons = [...document.querySelectorAll('button')]; - controller = createDictationController({ input, button: buttons[0], status: document.querySelector('p'), cancelButton: buttons[1], retryButton: buttons[2], engineSelect: document.querySelector('select'), getContext: () => context, setValue: value => { input.value = value; }, onStart: jest.fn(), fetchImpl, root }); + controller = createDictationController({ input, button: buttons[0], status: document.querySelector('p'), cancelButton: buttons[1], retryButton: buttons[2], engineSelect: document.querySelector('select'), getContext: () => context, setValue: value => { input.value = value; }, onStart: jest.fn(), onAlternateClick, fetchImpl, root }); await flush(); }); afterEach(() => controller.dispose()); + test('keeps the microphone icon and exposes recording state accessibly', async () => { + const icon = buttons[0].querySelector('svg'); + await controller.start(); + expect(buttons[0].querySelector('svg')).toBe(icon); + expect(buttons[0].textContent).toBe('Finish dictation'); + expect(buttons[0].getAttribute('aria-pressed')).toBe('true'); + expect(buttons[0].classList.contains('dictation-recording')).toBe(true); + controller.cancel(); + expect(buttons[0].textContent).toBe('Dictate'); + controller.setVoiceActive(true); + expect(buttons[0].classList.contains('active-voice')).toBe(true); + expect(buttons[0].title).toContain('end voice'); + }); + test('Shift-click selects voice without starting dictation', () => { + buttons[0].dispatchEvent(new MouseEvent('click', { shiftKey: true })); + expect(onAlternateClick).toHaveBeenCalledTimes(1); + expect(getUserMedia).not.toHaveBeenCalled(); + }); + test('touch long-press selects voice once and suppresses the following tap', () => { + jest.useFakeTimers(); + const pointer = (type, x = 0) => { + const event = new Event(type); + Object.assign(event, { pointerType: 'touch', clientX: x, clientY: 0 }); + buttons[0].dispatchEvent(event); + }; + pointer('pointerdown'); + jest.advanceTimersByTime(600); + pointer('pointerup'); buttons[0].click(); + expect(onAlternateClick).toHaveBeenCalledTimes(1); + expect(getUserMedia).not.toHaveBeenCalled(); + pointer('pointerdown'); pointer('pointerup'); buttons[0].click(); + expect(getUserMedia).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + test.each(['pointercancel', 'pointerleave', 'pointermove'])('touch %s cancels the voice hold', type => { + jest.useFakeTimers(); + for (const name of ['pointerdown', type]) { + const event = new Event(name); + Object.assign(event, { pointerType: 'touch', clientX: name === 'pointermove' ? 20 : 0, clientY: 0 }); + buttons[0].dispatchEvent(event); + } + jest.advanceTimersByTime(1000); + expect(onAlternateClick).not.toHaveBeenCalled(); + jest.useRealTimers(); + }); test('negotiates Safari MP4 and preserves edits made during transcription', async () => { await controller.start(); const finish = controller.finish();