feat(voice): render the voice control from a presenter

createVoiceControl now returns a presenter instead of building the
control with innerHTML: the session, cost, input, card and panel owners
write its state by part and listen for interactions, and VoiceControl.vue
renders it in the command dock, between the location bar and the visual
presets as before. Focus moves wait for the render; the waveform's bars
publish apart from the control and only when they change; the minutes
field keeps a draft until its owner writes it. Owner tests use a real
presenter on their own store, and view tests render the control.
This commit is contained in:
Sameh Khamis committed 2026-10-31 19:22:03 -07:00
1 parent 2ffd7a4afd
commit 1de4f635ca
47 files changed
+1691 -1645

No files matched your search

+248 -169
View File
@@ -1,7 +1,13 @@
import { nextTick } from 'vue';
import { t } from '../i18n/index.js';
import { useVoiceStore } from '../ui/stores/voice.js';
import { applicationPinia } from '../ui/vueRuntime.js';
// The assistant's name on its card. i18n-ignore: product name
const ASSISTANT_NAME = 'GEV';
export const ASSISTANT_NAME = 'GEV';
/** Bars in the dock waveform. */
export const VOICE_VISUALIZER_BARS = 15;
/**
* Short state label for the narrow voice heading. The `voice` context lets
@@ -26,174 +32,247 @@ export function voiceStatusLabel(status) {
}
}
/** Build the voice control independently of its connection backend. */
export function createVoiceControl({ reset = false, panel = false } = {}) {
let root = document.getElementById('gev-voice-control');
if (root && reset) {
root.remove();
root = null;
}
if (!root) {
root = document.createElement('div');
root.id = 'gev-voice-control';
root.dataset.status = 'idle';
root.dataset.speaker = 'idle';
root.innerHTML = `
<label id="gev-voice-provider-field" class="gev-voice-provider-field" hidden>
<span>${t('Voice provider')}</span>
<select id="gev-voice-provider" class="gev-field" aria-label="${t('Voice provider')}" title="${t('Changing provider reconnects voice if it is on.')}"></select>
</label>
${panel ? `<button id="gev-voice-settings-button" class="gev-voice-settings-button" type="button" aria-label="${t('Voice Settings')}" aria-controls="gev-voice-panel" aria-expanded="false"><span class="material-symbols-outlined gev-voice-settings-icon" aria-hidden="true">settings</span></button>` : ''}
<div class="gev-voice-heading">
<div class="gev-voice-kicker">${t('AI AGENT')}</div>
<div id="gev-voice-status">${voiceStatusLabel('idle')}</div>
${
panel
? `<span id="gev-voice-cost-warning" class="gev-voice-cost-value gev-voice-cost-warning" hidden role="status" aria-live="polite"></span>`
: `<div class="gev-voice-cost">
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" aria-pressed="false" title="${t('Voice model tier — applies next session')}">${t('STD')}</button>
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="${t('Estimated session cost')}">~$0.00</span>
</div>`
}
</div>
<button id="gev-voice-button" type="button" aria-label="${t('Voice control — activate to toggle voice; hold Space to speak')}" aria-describedby="gev-voice-help">
<span class="gev-mic-orbit"><img src="/mic.svg" alt="" /></span>
<span class="gev-mic-label">${t('ON/OFF')}</span>
</button>
<div class="gev-voice-visualizer" aria-hidden="true">
${Array.from({ length: 15 }, (_, index) => `<span style="--bar:${index}"></span>`).join('')}
</div>
<div class="gev-voice-readout">
<div id="gev-voice-detail">${t('VOICE STANDBY')}</div>
</div>
<div id="gev-voice-help" class="gev-voice-help-tray" role="tooltip">
<span class="gev-voice-help-kicker">${t('VOICE CONTROL')}</span>
<span class="gev-voice-help-detail">${t('Hold Space to speak · tap Space to activate focused controls')}</span>
</div>
${
panel
? `<section id="gev-voice-panel" class="gev-voice-panel" hidden aria-label="${t('Voice Settings')}">
<div class="gev-voice-panel-header">
<span class="gev-voice-panel-title">${t('VOICE')}</span>
<div class="gev-voice-panel-tabs" role="tablist" aria-label="${t('Voice Settings')}">
<button id="gev-voice-preview-tab" class="gev-voice-panel-tab" type="button" role="tab" aria-label="${t('Preview')}" title="${t('Preview')}" aria-selected="true" aria-controls="gev-voice-preview-panel"><span class="material-symbols-outlined gev-voice-panel-tab-icon" aria-hidden="true">visibility</span></button>
<button id="gev-voice-settings-tab" class="gev-voice-panel-tab" type="button" role="tab" aria-label="${t('Settings')}" title="${t('Settings')}" aria-selected="false" tabindex="-1" aria-controls="gev-voice-settings-panel"><span class="material-symbols-outlined gev-voice-panel-tab-icon" aria-hidden="true">settings</span></button>
</div>
<button id="gev-voice-settings-close" type="button" aria-label="${t('Close voice panel')}">×</button>
</div>
<div class="gev-voice-panel-body ui-scroll">
<section id="gev-voice-preview-panel" role="tabpanel" aria-labelledby="gev-voice-preview-tab">
<p id="gev-voice-preview-empty">${t('Voice replies appear here.')}</p>`
: ''
}
<section id="gev-voice-card" class="gev-voice-card ui-scroll" hidden aria-label="${t('Voice assistant')}">
<div class="gev-voice-card-header">
<span class="gev-voice-card-kicker">${t('VOICE')}</span>
<span id="gev-voice-card-phase" class="gev-voice-card-phase"></span>
<button id="gev-voice-card-dismiss" class="gev-voice-card-dismiss" type="button" aria-label="${t('Dismiss voice card')}">×</button>
</div>
<p id="gev-voice-card-user" class="gev-voice-card-caption" data-role="user" hidden><span id="gev-voice-card-user-who" class="gev-voice-card-who">${t('YOU')}</span><span id="gev-voice-card-user-text" class="gev-voice-card-text"></span></p>
<p id="gev-voice-card-pointer" class="gev-voice-card-pointer" hidden><span id="gev-voice-card-pointer-kind" class="gev-voice-card-who">${t('THIS')}</span><span class="gev-voice-card-sr">: </span><span id="gev-voice-card-pointer-text" class="gev-voice-card-pointer-label"></span></p>
<p id="gev-voice-card-assistant" class="gev-voice-card-caption" data-role="assistant" hidden><span class="gev-voice-card-who">${ASSISTANT_NAME}</span><span id="gev-voice-card-assistant-text" class="gev-voice-card-text"></span></p>
<ol id="gev-voice-card-plan" class="gev-voice-card-plan" aria-label="${t('Plan')}" hidden></ol>
<div id="gev-voice-card-result" class="gev-voice-card-result" hidden>
<div id="gev-voice-card-result-title" class="gev-voice-card-result-title"></div>
<div id="gev-voice-card-chips" class="gev-voice-card-chips" hidden></div>
<ul id="gev-voice-card-lines" class="gev-voice-card-lines"></ul>
<ol id="gev-voice-card-referents" class="gev-voice-card-referents" aria-label="${t('Referenced items')}" hidden></ol>
<details id="gev-voice-card-notes" class="gev-voice-card-notes" hidden>
<summary>${t('NOTES & SOURCES')}</summary>
<ul id="gev-voice-card-note-list" class="gev-voice-card-lines"></ul>
</details>
</div>
<div id="gev-voice-card-live" class="gev-voice-card-sr" aria-live="polite" aria-atomic="true"></div>
</section>
${
panel
? `</section>
<section id="gev-voice-settings-panel" role="tabpanel" aria-labelledby="gev-voice-settings-tab" hidden>
<label class="gev-voice-preference gev-voice-preference-toggle"><input id="gev-voice-preview-enabled" type="checkbox" checked aria-describedby="gev-voice-preview-note" /><span>${t('Show Voice Preview')}</span></label>
<p id="gev-voice-preview-note" class="gev-voice-settings-note"></p>
<div class="gev-voice-timeout-group">
<label class="gev-voice-preference gev-voice-setting-title" for="gev-voice-timeout-select">${t('Voice inactivity timeout')}</label>
<div class="gev-voice-timeout-controls">
<select id="gev-voice-timeout-select" class="gev-field" aria-describedby="gev-voice-timeout-note gev-voice-timeout-status">
<option value="3">3 ${t('Minutes')}</option><option value="5" selected>5 ${t('Minutes')}</option><option value="10">10 ${t('Minutes')}</option><option value="15">15 ${t('Minutes')}</option>
<option value="custom">${t('Custom')}</option><option value="0">${t('None')}</option>
</select>
<label class="gev-voice-timeout-unit" for="gev-voice-timeout-input">${t('Minutes')}</label>
<input id="gev-voice-timeout-input" type="number" min="1" max="60" step="1" value="5" aria-describedby="gev-voice-timeout-note" />
</div>
<p id="gev-voice-timeout-note" class="gev-voice-settings-note">${t('These preferences stay in this browser.')}</p>
<p id="gev-voice-timeout-status" class="gev-voice-settings-note" role="status" aria-live="polite"></p>
</div>
<div class="gev-voice-cost-settings">
<div class="gev-voice-cost-setting-row">
<span>${t('Voice model tier — applies next session')}</span>
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" aria-pressed="false" title="${t('Voice model tier — applies next session')}">${t('STD')}</button>
</div>
<div class="gev-voice-cost-setting-row">
<span>${t('Estimated session cost')}</span>
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="${t('Estimated session cost')}">~$0.00</span>
</div>
</div>
</section>
</div>
</section>`
: ''
}
<div class="gev-voice-error-tray" role="alert" aria-live="assertive">
<div class="gev-voice-error-header">
<span>${t('VOICE SYSTEM ERROR')}</span>
<button class="gev-voice-error-dismiss" type="button">${t('DISMISS')}</button>
</div>
<div id="gev-voice-error-detail"></div>
<div class="gev-voice-error-hint">${t('Check microphone permission and network access, then try again.')}</div>
</div>
`;
const commandDock = document.getElementById('command-dock');
if (commandDock) {
const locationBar = document.getElementById('location-bar');
const controlPanel = document.getElementById('control-panel');
commandDock.appendChild(root);
if (locationBar) commandDock.insertBefore(locationBar, root);
if (controlPanel) commandDock.appendChild(controlPanel);
} else {
document.body.appendChild(root);
}
root
.querySelector('.gev-voice-error-dismiss')
?.addEventListener('click', () => {
root.classList.add('error-dismissed');
});
}
/** The card before its first render: hidden, with its default labels. */
function initialCard() {
return {
root,
voicePanel: root.querySelector('#gev-voice-panel'),
previewPanel: root.querySelector('#gev-voice-preview-panel'),
voiceSettingsPanel: root.querySelector('#gev-voice-settings-panel'),
previewTab: root.querySelector('#gev-voice-preview-tab'),
settingsTab: root.querySelector('#gev-voice-settings-tab'),
voiceSettingsButton: root.querySelector('#gev-voice-settings-button'),
voiceSettingsClose: root.querySelector('#gev-voice-settings-close'),
previewEmpty: root.querySelector('#gev-voice-preview-empty'),
voicePreviewInput: root.querySelector('#gev-voice-preview-enabled'),
voicePreviewNote: root.querySelector('#gev-voice-preview-note'),
voiceTimeoutSelect: root.querySelector('#gev-voice-timeout-select'),
voiceTimeoutInput: root.querySelector('#gev-voice-timeout-input'),
voiceTimeoutNote: root.querySelector('#gev-voice-timeout-note'),
voiceTimeoutStatus: root.querySelector('#gev-voice-timeout-status'),
providerField: root.querySelector('#gev-voice-provider-field'),
providerSelect: root.querySelector('#gev-voice-provider'),
button: root.querySelector('#gev-voice-button'),
buttonLabel: root.querySelector('.gev-mic-label'),
status: root.querySelector('#gev-voice-status'),
detail: root.querySelector('#gev-voice-detail'),
helpDetail: root.querySelector('.gev-voice-help-detail'),
errorDetail: root.querySelector('#gev-voice-error-detail'),
tierButton: root.querySelector('#gev-voice-tier'),
costValue: root.querySelector('#gev-voice-cost-value'),
costWarning: root.querySelector('#gev-voice-cost-warning'),
card: root.querySelector('#gev-voice-card'),
visible: false,
busy: null,
phase: '',
user: { visible: false, text: '', label: t('YOU') },
pointer: { visible: false, kind: t('THIS'), text: '' },
assistant: { visible: false, text: '' },
steps: [],
result: {
visible: false,
title: '',
chips: [],
lines: [],
referents: [],
notes: [],
},
announce: '',
};
}
/** The control as it first renders, before any owner writes to it. */
function initialState(panel) {
return {
panel,
root: {
status: 'idle',
speaker: 'idle',
provider: null,
providerChoice: null,
pushToTalk: null,
microphone: null,
rowGrace: false,
errorDismissed: false,
},
// The provider field and its select.
provider: {
hidden: true,
options: [],
value: '',
title: t('Changing provider reconnects voice if it is on.'),
},
tier: {
hidden: false,
text: t('STD'),
pressed: false,
title: t('Voice model tier — applies next session'),
},
cost: {
hidden: false,
text: '~$0.00',
level: 'ok',
title: t('Estimated session cost'),
},
costWarning: { hidden: true, text: '', level: null, title: null },
button: {
label: t('Voice control — activate to toggle voice; hold Space to speak'),
pressed: null,
},
mic: { text: t('ON/OFF') },
status: { text: voiceStatusLabel('idle') },
detail: { text: t('VOICE STANDBY'), title: null },
help: {
text: t('Hold Space to speak · tap Space to activate focused controls'),
},
error: { text: '' },
voicePanel: {
hidden: true,
view: null,
previewHidden: false,
settingsHidden: true,
settingsExpanded: false,
selected: 'preview',
},
previewEmpty: { hidden: false, text: t('Voice replies appear here.') },
preferences: {
previewChecked: true,
previewNote: '',
timeoutChoice: '5',
timeoutMinutes: '5',
timeoutInputHidden: false,
timeoutNote: t('These preferences stay in this browser.'),
timeoutStatus: '',
// Bumped by every owner write of the fields, so the view restores a
// refused edit but leaves a draft alone across other renders.
revision: 0,
},
card: initialCard(),
};
}
// The rendered elements the presenter focuses or measures, by name.
const ELEMENT_IDS = Object.freeze({
root: 'gev-voice-control',
button: 'gev-voice-button',
provider: 'gev-voice-provider',
voicePanel: 'gev-voice-panel',
previewPanel: 'gev-voice-preview-panel',
settingsPanel: 'gev-voice-settings-panel',
previewTab: 'gev-voice-preview-tab',
settingsTab: 'gev-voice-settings-tab',
settingsButton: 'gev-voice-settings-button',
card: 'gev-voice-card',
});
let controlCount = 0;
const sameBars = (a, b) =>
a === b ||
(Array.isArray(a) &&
Array.isArray(b) &&
a.length === b.length &&
a.every(
(bar, index) =>
bar.level === b[index].level && bar.opacity === b[index].opacity,
));
/**
* The voice control, independent of its connection backend. Session owners
* write its state by part (`update('detail', {text})`) and listen for what
* the person does (`on('button', fn)`); the command dock's VoiceControl view
* renders it from the voice store. A newer control replaces this one there.
* @param {object} [options]
* @param {boolean} [options.panel] Show the Voice Settings panel and preview.
*/
export function createVoiceControl({
panel = false,
store = useVoiceStore(applicationPinia()),
} = {}) {
const id = ++controlCount;
const listeners = new Map();
let state = initialState(Boolean(panel));
// Each bar's `--audio-level` and `--audio-opacity`, or null at rest.
let bars = null;
let removed = false;
const owns = () => !removed && store.controls === ui;
const publish = () => {
if (owns()) store.publish({ ...state, id });
};
const element = (name) =>
owns()
? (globalThis.document?.getElementById(ELEMENT_IDS[name]) ?? null)
: null;
const shown = (node) =>
Boolean(
node?.checkVisibility?.({
opacityProperty: true,
visibilityProperty: true,
}) ?? node,
);
const ui = {
get panel() {
return state.panel;
},
/** The current state; read-only, replaced on every update. */
get state() {
return state;
},
/** Merge `patch` into one part of the control. */
update(part, patch) {
if (removed) return;
state = { ...state, [part]: { ...state[part], ...patch } };
publish();
},
/** Replace one part of the control. */
replace(part, value) {
if (removed) return;
state = { ...state, [part]: value };
publish();
},
/**
* Listen for an interaction the view reports: `button`, `tier`,
* `provider-change`, `settings-button`, `settings-close`, `tab`,
* `tab-keydown`, `panel-keydown`, `pointerenter`, `pointerleave`,
* `preview-change`, `timeout-select`, `timeout-input`, `card-dismiss`.
* @returns {() => void} Removes the listener.
*/
on(name, listener) {
if (!listeners.has(name)) listeners.set(name, new Set());
listeners.get(name).add(listener);
return () => listeners.get(name)?.delete(listener);
},
/** The view reports an interaction; listeners run in the order added. */
emit(name, ...args) {
if (removed) return;
for (const listener of [...(listeners.get(name) ?? [])])
listener(...args);
},
/** The waveform's bars, or null at rest. */
get bars() {
return bars;
},
/**
* Show the waveform's bars (null rests it). Published apart from the
* rest of the control, and only when they change.
* @param {?Array<{level: string, opacity: string}>} next
*/
setBars(next) {
if (removed || sameBars(bars, next)) return;
bars = next;
if (owns()) store.publishBars(bars);
},
/** The error tray's DISMISS. */
dismissError() {
ui.update('root', { errorDismissed: true });
},
/** The rendered element by name, or null before it renders. */
element,
/** Whether the named element is rendered and visible on the page. */
visible: (name) => shown(element(name)),
/**
* Once the view has rendered the latest state, focus the first of the
* named elements that is visible, or with `anyVisibility`, that is
* rendered (focus may be what reveals it).
* @param {string[]} names
* @param {{anyVisibility?: boolean}} [options]
*/
focus(names, { anyVisibility = false } = {}) {
return nextTick(() => {
const target = names
.map(element)
.find((node) => node && (anyVisibility || shown(node)));
target?.focus?.();
return target ?? null;
});
},
/** Remove the control from the dock, unless a newer one replaced it. */
remove() {
if (removed) return;
if (store.controls === ui) {
store.connect(null);
store.publish(null);
store.publishBars(null);
}
removed = true;
listeners.clear();
},
};
store.connect(ui);
store.publishBars(null);
publish();
return ui;
}