Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.';
Expand All @@ -6726,17 +6727,17 @@ 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) {
sendButton.title = 'Remove the failed image attachment before sending.';
} else if (uploadInFlight) {
sendButton.title = ATTACHMENT_UPLOAD_SEND_BLOCK_MESSAGE;
} else {
sendButton.removeAttribute('title');
sendButton.title = sessionBusy ? 'Queue Prompt' : 'Send Prompt';
}
}

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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(); }
});
}
Expand All @@ -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 () => {
Expand Down
55 changes: 49 additions & 6 deletions src/frontend/web/von_interface/static/js/dictation.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -34,22 +35,46 @@ 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;
let state = 'idle';
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;
Expand All @@ -67,6 +92,7 @@ export function createDictationController({ input, button, status, cancelButton,
}

function cancel() {
clearPress();
const capture = current;
current = null;
if (capture) {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10926,11 +10926,13 @@ describe('chat session composer state', () => {
});

await expect(__testOnly_refreshChatSessionTabs()).resolves.toBeUndefined();
document.getElementById('sendButton').innerHTML = '<svg></svg><span class="button-label sr-only">Send Prompt</span>';
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();
Expand Down Expand Up @@ -12716,7 +12718,7 @@ describe('scroll to latest message affordance', () => {
<div class="chat-session-tabs-row">
<div id="chatSessionTabs"></div>
</div>
<div id="scrollableField"></div>
<div id="scrollableField"></div><div class="chat-composer"></div>
</div>
</div>
`;
Expand All @@ -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');

Expand Down
5 changes: 5 additions & 0 deletions src/frontend/web/von_interface/static/js/utils/buttonLabel.js
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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; }
Expand Down
49 changes: 47 additions & 2 deletions src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
14 changes: 10 additions & 4 deletions src/frontend/web/von_interface/templates/chat_tab.html
Original file line number Diff line number Diff line change
Expand Up @@ -254,18 +254,24 @@ <h3 id="conversationReferenceInspectorTitle" class="chat-reference-inspector-tit

<div class="chat-composer-actions">
<div class="chat-composer-primary-actions">
<button id="sendButton" class="btn btn-primary">Send Prompt</button>
<button id="dictateButton" class="btn btn-secondary" type="button" title="Dictate with your microphone">
Dictate
<button id="sendButton" class="btn btn-primary composer-icon-button" type="button" title="Send Prompt">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5m-7 7 7-7 7 7" /></svg>
<span class="button-label sr-only">Send Prompt</span>
</button>
<button id="voiceConversationButton" class="btn btn-secondary" type="button" aria-pressed="false">Start voice</button>
<button id="dictateButton" class="btn btn-secondary composer-icon-button" type="button" aria-describedby="microphoneHelp" title="Click to dictate into your draft. Shift-click or long-press to start a voice conversation: speech is sent automatically and Von replies aloud.">
<svg class="microphone-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3" /><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8" /></svg>
<svg class="dictation-stop-icon" aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>
<span class="button-label sr-only">Dictate</span>
</button>
<span id="microphoneHelp" class="sr-only">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.</span>
<button id="cancelDictationButton" class="btn btn-secondary" type="button" hidden>Cancel dictation</button>
<button id="retryDictationButton" class="btn btn-secondary" type="button" hidden>Retry recording</button>
</div>

<details class="chat-composer-more-actions">
<summary>More actions</summary>
<div class="button-row">
<button id="voiceConversationButton" class="btn btn-secondary" type="button" aria-pressed="false" title="Have a voice conversation: speech is sent automatically and Von replies aloud. You can also Shift-click or long-press the microphone.">Start voice</button>
<label for="dictationEngineSelect">Dictation</label>
<select id="dictationEngineSelect" aria-describedby="dictationStatus">
<option value="streaming">Live transcription · context-aware</option>
Expand Down
Loading
Loading