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

+2 -1
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import { createVoiceSession } from '../voice/session.js';
import { createGevActionRunner } from '../voice/gevActions.js';
import { createGeminiSession } from '../voice/geminiSession.js';
import { createTestVoiceUi } from './voiceUi.mjs';
import { createNarrationScheduler } from '../voice/narration.js';
export const turnOfEventLoop = () =>
@@ -158,7 +159,7 @@ export function nativeIntegrationFixture(t) {
createAdapter: (hooks) =>
createGeminiSession({
...hooks,
ui: { root: { remove() {} } },
ui: createTestVoiceUi(),
dataManager,
now: clock.now,
setTimer: clock.setTimer,
+2 -1
View File
@@ -1,4 +1,5 @@
import { GeminiSessionController } from '../voice/geminiSession.js';
import { createTestVoiceUi } from './voiceUi.mjs';
import { createNarrationScheduler } from '../voice/narration.js';
const endpoint =
@@ -121,7 +122,7 @@ function harness(overrides = {}) {
calls.push({ name, args, options });
return { ok: true, action: name };
},
ui: { root: { remove() {} } },
ui: createTestVoiceUi(),
radioLayer: radio,
now: () => clock,
setTimer: (fn, ms) => {
+16
View File
@@ -0,0 +1,16 @@
import { createPinia } from 'pinia';
import { useVoiceStore } from '../ui/stores/voice.js';
import { createVoiceControl } from '../voice/control.js';
/**
* A real voice control presenter on its own store, for owner tests that run
* without a page. Read what it shows from `ui.state`; report interactions
* with `ui.emit(name, ...args)`.
* @param {{panel?: boolean, pinia?: import('pinia').Pinia}} [options]
*/
export function createTestVoiceUi({
panel = false,
pinia = createPinia(),
} = {}) {
return createVoiceControl({ panel, store: useVoiceStore(pinia) });
}
+39
View File
@@ -0,0 +1,39 @@
import { createApp, h, nextTick } from 'vue';
import { createPinia, storeToRefs } from 'pinia';
import { useVoiceStore } from '../ui/stores/voice.js';
import VoiceControl from '../ui/views/panels/VoiceControl.vue';
/**
* Render whichever voice control the store presents, as the command dock
* does, into the current document. Import registerDom.mjs first.
* @param {import('node:test').TestContext} t
* @returns {{pinia: import('pinia').Pinia, settle: () => Promise<void>}}
*/
export function mountVoiceView(t) {
const doc = globalThis.document;
// happy-dom lacks the browser's `[hidden]` rule, which visibility checks
// and focus moves rely on.
if (!doc.getElementById('test-ua-hidden')) {
const style = doc.head.appendChild(doc.createElement('style'));
style.id = 'test-ua-hidden';
style.textContent = '[hidden] { display: none; }';
}
doc.body.replaceChildren();
const host = doc.body.appendChild(doc.createElement('div'));
const pinia = createPinia();
const { view } = storeToRefs(useVoiceStore(pinia));
const app = createApp({
render: () =>
view.value
? h(VoiceControl, { key: view.value.id, view: view.value })
: null,
}).use(pinia);
app.mount(host);
t.after(() => app.unmount());
// Renders, then any focus the owners asked for after it.
const settle = async () => {
await nextTick();
await nextTick();
};
return { pinia, settle };
}