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

+161 -86
View File
@@ -1,12 +1,54 @@
import '../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { createVoiceControl } from '../control.js';
import { useVoiceStore } from '../../ui/stores/voice.js';
import { createGevActionRunner } from '../gevActions.js';
import { createVoiceCommands as bind } from '../sessionCommands.js';
import { createVoiceCommands as compose } from '../commands.js';
import { createProviderCommands } from '../providerCommands.js';
import { createVoicePanelHost } from './host.js';
import { installPanelDom, controlledClock } from './testDom.mjs';
import { createTestVoiceUi } from '../../testSupport/voiceUi.mjs';
import { mountVoiceView } from '../../testSupport/voiceView.mjs';
const $ = (selector) => globalThis.document.querySelector(selector);
const exists = (selector) => $(selector) !== null;
const active = () => globalThis.document.activeElement?.id ?? null;
const key = (node, name) =>
node.dispatchEvent(
new KeyboardEvent('keydown', {
key: name,
bubbles: true,
cancelable: true,
}),
);
function controlledClock() {
let now = 0,
next = 0;
const tasks = new Map();
return {
now: () => now,
setTimer(fn, ms) {
const id = ++next;
tasks.set(id, { fn, at: now + ms });
return id;
},
clearTimer: (id) => tasks.delete(id),
advance(ms) {
now += ms;
for (const [id, task] of [...tasks])
if (task.at <= now && tasks.delete(id)) task.fn();
},
};
}
// The custom inactivity minutes, then the select's Custom choice.
function chooseCustomTimeout(ui, minutes) {
ui.update('preferences', { timeoutMinutes: minutes });
ui.emit('timeout-select', 'custom');
}
function factory(instances, { stopError = null, native = false } = {}) {
return (hooks) => {
@@ -78,8 +120,10 @@ function factory(instances, { stopError = null, native = false } = {}) {
return adapter;
};
}
// With `view`, the real VoiceControl renders the control from its store.
function mounted(t, options = {}) {
const doc = installPanelDom(t);
const view = options.view ? mountVoiceView(t) : null;
const pinia = view?.pinia ?? createPinia();
const clock = controlledClock();
const host = createVoicePanelHost({
storage: options.storage ?? null,
@@ -91,6 +135,7 @@ function mounted(t, options = {}) {
runner: options.runner ?? (async () => ({ ok: true })),
dataManager: { layers: new Map() },
createSession: factory(instances, options),
createControl: (control) => createTestVoiceUi({ ...control, pinia }),
panelHost: host,
nativePanelProvider: options.native
? (options.nativeProvider ?? 'gemini')
@@ -102,42 +147,66 @@ function mounted(t, options = {}) {
} catch {}
host.destroy();
});
return { doc, clock, host, instances, commands, ui: commands.ui };
return {
clock,
host,
instances,
commands,
ui: commands.ui,
settle: view?.settle,
};
}
test('actual control default remains opt-out; panel markup has unique controlled panes and an outside alert', (t) => {
const doc = installPanelDom(t);
const baseline = createVoiceControl();
assert.equal(baseline.voicePanel, null);
const ui = createVoiceControl({ reset: true, panel: true });
assert.equal(doc.getElementById('gev-voice-panel'), ui.voicePanel);
test('actual control default remains opt-out; panel markup has unique controlled panes and an outside alert', async (t) => {
const view = mountVoiceView(t);
const store = useVoiceStore(view.pinia);
const baseline = createVoiceControl({ store });
t.after(() => baseline.remove());
assert.equal(baseline.panel, false);
await view.settle();
assert.equal(exists('#gev-voice-control'), true);
assert.equal(exists('#gev-voice-panel'), false);
const ui = createVoiceControl({ panel: true, store });
t.after(() => ui.remove());
await view.settle();
const panel = $('#gev-voice-panel');
assert.equal(panel !== null, true);
assert.equal(
ui.voiceSettingsButton.getAttribute('aria-controls'),
ui.voicePanel.id,
$('#gev-voice-settings-button').getAttribute('aria-controls'),
panel.id,
);
assert.equal(ui.previewTab.getAttribute('aria-controls'), ui.previewPanel.id);
for (const [tab, pane] of [
['#gev-voice-preview-tab', 'gev-voice-preview-panel'],
['#gev-voice-settings-tab', 'gev-voice-settings-panel'],
]) {
assert.equal($(tab).getAttribute('aria-controls'), pane);
assert.equal(exists(`#${pane}`), true);
}
assert.equal(
ui.settingsTab.getAttribute('aria-controls'),
ui.voiceSettingsPanel.id,
$('#gev-voice-preview-panel').contains($('#gev-voice-card')),
true,
);
assert.equal(ui.previewPanel.contains(ui.card), true);
const tray = $('#gev-voice-control .gev-voice-error-tray');
assert.equal(tray !== null, true);
assert.equal(panel.contains(tray), false);
assert.equal(
ui.voicePanel.contains(ui.root.querySelector('.gev-voice-error-tray')),
false,
$('#gev-voice-control').querySelectorAll('#gev-voice-card').length,
1,
);
assert.equal(ui.root.querySelectorAll('#gev-voice-card').length, 1);
});
test('actual binder/card has launcher toggle, idempotent tabs, focus and disabled Preview', (t) => {
const { ui, commands, doc } = mounted(t);
ui.voiceSettingsButton.fire('click');
test('actual binder/card has launcher toggle, idempotent tabs, focus and disabled Preview', async (t) => {
const { ui, commands, settle } = mounted(t, { view: true });
await settle();
$('#gev-voice-settings-button').click();
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
ui.voiceSettingsButton.fire('click');
$('#gev-voice-settings-button').click();
assert.equal(commands.voicePanel.snapshot().panelOpen, false);
ui.settingsTab.fire('click');
ui.settingsTab.fire('keydown', { key: 'End' });
await settle();
$('#gev-voice-settings-tab').click();
key($('#gev-voice-settings-tab'), 'End');
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
ui.settingsTab.fire('click');
$('#gev-voice-settings-tab').click();
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
commands.voicePanel.select('preview');
commands.voiceCard.handle({
@@ -146,17 +215,19 @@ test('actual binder/card has launcher toggle, idempotent tabs, focus and disable
final: true,
text: 'A visible reply',
});
const dismiss = ui.root.querySelector('#gev-voice-card-dismiss');
await settle();
const dismiss = $('#gev-voice-card-dismiss');
dismiss.focus();
dismiss.fire('click');
assert.equal(ui.card.hidden, true);
dismiss.click();
await settle();
assert.equal(ui.state.card.visible, false);
assert.equal($('#gev-voice-card').hidden, true);
assert.equal(commands.voicePanel.snapshot().panelOpen, true);
assert.equal(doc.activeElement?.id, ui.previewTab.id);
ui.voicePanel.fire('keydown', { key: 'Escape', target: doc.activeElement });
assert.equal(active(), 'gev-voice-preview-tab');
key(globalThis.document.activeElement, 'Escape');
assert.equal(commands.voicePanel.snapshot().panelOpen, false);
commands.voicePanel.select('settings');
ui.voicePreviewInput.checked = false;
ui.voicePreviewInput.fire('change');
ui.emit('preview-change', false);
commands.voicePanel.select('preview');
commands.voiceCard.handle({
type: 'transcript',
@@ -164,11 +235,13 @@ test('actual binder/card has launcher toggle, idempotent tabs, focus and disable
final: true,
text: 'A hidden reply',
});
assert.equal(ui.card.hidden, true);
assert.equal(ui.previewEmpty.textContent, 'Preview is off.');
ui.previewTab.focus();
ui.voicePanel.fire('keydown', { key: 'Escape' });
assert.equal(doc.activeElement, ui.voiceSettingsButton);
assert.equal(ui.state.card.visible, false);
assert.equal(ui.state.previewEmpty.text, 'Preview is off.');
await settle();
$('#gev-voice-preview-tab').focus();
key(globalThis.document.activeElement, 'Escape');
await settle();
assert.equal(active(), 'gev-voice-settings-button');
commands.voiceCard.handle({
type: 'transcript',
role: 'assistant',
@@ -179,7 +252,7 @@ test('actual binder/card has launcher toggle, idempotent tabs, focus and disable
});
test('actual facade remount keeps failed-save live preferences/view and manual close, with no restore writes or focus theft', async (t) => {
const doc = installPanelDom(t);
const view = mountVoiceView(t);
const writes = [];
const storage = {
getItem: () => null,
@@ -197,7 +270,13 @@ test('actual facade remount keeps failed-save live preferences/view and manual c
supportedProviders: ['openai', 'gemini'],
},
{
bind: (options) => bind({ ...options, panelHost: host }),
bind: (options) =>
bind({
...options,
panelHost: host,
createControl: (control) =>
createTestVoiceUi({ ...control, pinia: view.pinia }),
}),
factories: { openai: factory(instances), gemini: factory(instances) },
storage,
},
@@ -208,18 +287,17 @@ test('actual facade remount keeps failed-save live preferences/view and manual c
});
const old = commands.ui;
commands.voicePanel.select('settings');
old.voicePreviewInput.checked = false;
old.voicePreviewInput.fire('change');
old.voiceTimeoutInput.value = '7';
old.voiceTimeoutSelect.value = 'custom';
old.voiceTimeoutSelect.fire('change');
old.emit('preview-change', false);
chooseCustomTimeout(old, '7');
const preferenceWrites = () =>
writes.filter(([key]) => key.startsWith('godsEyeView.voice.')).length;
assert.equal(preferenceWrites(), 2);
old.providerSelect.value = 'gemini';
old.providerSelect.fire('change');
assert.notEqual(commands.ui, old);
assert.equal(old.root.isConnected, false);
await view.settle();
const oldRoot = $('#gev-voice-control');
old.emit('provider-change', 'gemini');
assert.equal(commands.ui !== old, true);
await view.settle();
assert.equal(oldRoot.isConnected, false);
assert.equal(commands.voicePanel.snapshot().settingsOpen, true);
assert.deepEqual(commands.getVoicePreferences(), {
previewEnabled: false,
@@ -227,17 +305,16 @@ test('actual facade remount keeps failed-save live preferences/view and manual c
previewPersisted: false,
timeoutPersisted: false,
});
assert.equal(commands.ui.voicePreviewInput.checked, false);
assert.match(commands.ui.voiceTimeoutNote.textContent, /could not be saved/);
assert.equal(commands.ui.state.preferences.previewChecked, false);
assert.equal($('#gev-voice-preview-enabled').checked, false);
assert.match(commands.ui.state.preferences.timeoutNote, /could not be saved/);
assert.equal(preferenceWrites(), 2);
assert.equal(doc.activeElement, commands.ui.providerSelect);
commands.ui.voicePreviewInput.checked = true;
commands.ui.voicePreviewInput.fire('change');
assert.equal(active(), 'gev-voice-provider');
commands.ui.emit('preview-change', true);
commands.voicePanel.close();
await commands.start();
const retired = instances[1];
commands.ui.providerSelect.value = 'openai';
commands.ui.providerSelect.fire('change');
commands.ui.emit('provider-change', 'openai');
await commands.start();
assert.equal(commands.session.isActive(), true);
retired.hooks.emit({
@@ -268,14 +345,14 @@ test('explicit successful mic Stop closes; failed Stop idle and startup error do
const good = mounted(t);
await good.commands.session.start();
good.commands.voicePanel.select('settings');
good.ui.button.fire('click');
good.ui.emit('button');
assert.equal(good.commands.voicePanel.snapshot().panelOpen, false);
const error = Error('stop failed');
const bad = mounted(t, { stopError: error });
await bad.commands.session.start();
bad.commands.voicePanel.select('settings');
assert.throws(
() => bad.ui.button.fire('click'),
() => bad.ui.emit('button'),
(value) => value === error,
);
assert.equal(bad.commands.session.state, 'idle');
@@ -296,7 +373,7 @@ test('reentrant manual choice/new start during Stop cannot close the successor c
if (event.type === 'state' && event.state === 'idle')
f.commands.voicePanel.select('preview');
});
f.ui.button.fire('click');
f.ui.emit('button');
assert.equal(f.commands.voicePanel.snapshot().panelOpen, true);
assert.equal(f.commands.voicePanel.snapshot().view, 'preview');
unsubscribe();
@@ -306,9 +383,7 @@ test('integrated inactivity rechecks held PTT at expiry, then normal stop closes
const f = mounted(t, { native: true });
await f.commands.session.start();
f.commands.voicePanel.select('settings');
f.ui.voiceTimeoutInput.value = '1';
f.ui.voiceTimeoutSelect.value = 'custom';
f.ui.voiceTimeoutSelect.fire('change');
chooseCustomTimeout(f.ui, '1');
f.instances[0].input.pushToTalkKeyHeld = true;
f.clock.advance(60000);
assert.equal(f.instances[0].stops, 0);
@@ -321,8 +396,7 @@ test('integrated inactivity rechecks held PTT at expiry, then normal stop closes
test('None cancels integrated app timer and unknown custom hosts never guess expiry', async (t) => {
const native = mounted(t, { native: true });
await native.commands.session.start();
native.ui.voiceTimeoutSelect.value = '0';
native.ui.voiceTimeoutSelect.fire('change');
native.ui.emit('timeout-select', '0');
native.clock.advance(600000);
assert.equal(native.instances[0].stops, 0);
assert.equal(native.commands.getVoicePreferences().minutes, 0);
@@ -330,17 +404,15 @@ test('None cancels integrated app timer and unknown custom hosts never guess exp
await unknown.commands.session.start();
unknown.clock.advance(600000);
assert.equal(unknown.instances[0].stops, 0);
assert.match(unknown.ui.voiceTimeoutStatus.textContent, /unavailable/);
assert.match(unknown.ui.state.preferences.timeoutStatus, /unavailable/);
});
test('transient native OpenAI response activity does not announce connection unavailability', async (t) => {
const f = mounted(t, { native: true, nativeProvider: 'openai' });
await f.commands.session.start();
f.ui.voiceTimeoutInput.value = '1';
f.ui.voiceTimeoutSelect.value = 'custom';
f.ui.voiceTimeoutSelect.fire('change');
chooseCustomTimeout(f.ui, '1');
f.instances[0].controller._turns.observeSpeech({ type: 'response.created' });
assert.equal(f.ui.voiceTimeoutStatus.textContent, '');
assert.equal(f.ui.state.preferences.timeoutStatus, '');
f.clock.advance(60000);
assert.equal(f.instances[0].stops, 0, 'unknown output still prevents expiry');
f.instances[0].controller._turns.observeSpeech({
@@ -351,7 +423,6 @@ test('transient native OpenAI response activity does not announce connection una
});
test('native factory with custom controller remains uninstrumented despite panel opt-in', (t) => {
installPanelDom(t);
let inspected = 0;
const commands = compose({
panel: true,
@@ -404,7 +475,6 @@ async function directActions(
t,
{ storage = null, delayExit = false, exposePanel = true } = {},
) {
installPanelDom(t);
let f, releaseExit;
const state = { active: true, exits: 0, visual: [] };
const styleManager = {
@@ -479,8 +549,7 @@ test('named voice settings exits Cockpit directly; Preview stays disabled and vi
});
assert.equal(f.state.exits, 1);
assert.equal(result.settingsOpen, true);
f.ui.voicePreviewInput.checked = false;
f.ui.voicePreviewInput.fire('change');
f.ui.emit('preview-change', false);
const preview = await f.run('set_panel_open', {
panelId: 'voice control',
open: true,
@@ -575,7 +644,7 @@ test('voice timeout change preserves a pending voice reveal', async (t) => {
});
test('active provider output reveals Preview after done; manual choice wins until genuine input, replays do not reset idle', async (t) => {
const f = mounted(t, { native: true, nativeProvider: 'openai' });
const f = mounted(t, { native: true, nativeProvider: 'openai', view: true });
await f.commands.session.start();
const emit = f.instances[0].hooks.emit;
const reply = (text, responseId = 'r1', extra = {}) =>
@@ -638,6 +707,7 @@ test('Preview off and actual Cockpit state suppress automatic presentation witho
let cockpit = false;
const f = mounted(t, {
native: true,
view: true,
readCockpitState: () => ({ active: cockpit }),
});
await f.commands.session.start();
@@ -645,32 +715,37 @@ test('Preview off and actual Cockpit state suppress automatic presentation witho
const reply = (text) =>
emit({ type: 'transcript', role: 'assistant', text, final: true });
reply('Visible');
f.ui.previewTab.focus();
await f.settle();
$('#gev-voice-preview-tab').focus();
cockpit = true;
window.dispatchEvent(new Event('gev:cockpit-mode-changed'));
assert.equal(f.ui.voicePanel.hidden, true);
assert.equal(f.doc.activeElement, f.ui.voiceSettingsButton);
assert.equal(f.ui.state.voicePanel.hidden, true);
await f.settle();
assert.equal(active(), 'gev-voice-settings-button');
f.commands.voicePanel.select('settings', { focus: true });
assert.equal(f.doc.activeElement, f.ui.settingsTab);
await f.settle();
assert.equal(active(), 'gev-voice-settings-tab');
f.commands.voicePanel.select('preview', { focus: true });
assert.equal(f.doc.activeElement, f.ui.voiceSettingsButton);
const outside = f.doc.createElement('button');
f.doc.body.appendChild(outside);
await f.settle();
assert.equal(active(), 'gev-voice-settings-button');
const outside = document.createElement('button');
outside.id = 'unrelated';
document.body.appendChild(outside);
outside.focus();
window.dispatchEvent(new Event('gev:cockpit-mode-changed'));
assert.equal(f.doc.activeElement, outside);
await f.settle();
assert.equal(active(), 'unrelated');
assert.equal(f.commands.voicePanel.snapshot().previewVisible, false);
reply('Suppressed in Cockpit');
assert.equal(f.ui.voicePanel.hidden, true);
assert.equal(f.ui.state.voicePanel.hidden, true);
f.commands.voicePanel.close();
cockpit = false;
window.dispatchEvent(new Event('gev:cockpit-mode-changed'));
assert.equal(f.ui.voicePanel.hidden, true);
f.ui.voicePreviewInput.checked = false;
f.ui.voicePreviewInput.fire('change');
assert.equal(f.ui.state.voicePanel.hidden, true);
f.ui.emit('preview-change', false);
emit({ type: 'interruption', reason: 'user-speech' });
reply('Preference stays off');
assert.equal(f.ui.voicePanel.hidden, true);
assert.equal(f.ui.state.voicePanel.hidden, true);
assert.equal(f.commands.getVoicePreferences().previewEnabled, false);
});