mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
2ffd7a4afd
commit
1de4f635ca
47 files changed
+1691
-1645
No files matched your search
@@ -20,6 +20,14 @@
|
||||
takes `omitViews` (for example `['provider-settings']`) for views an
|
||||
application leaves out (samehkhamis).
|
||||
|
||||
- `./voice/control`'s `createVoiceControl({ panel })` returns the voice
|
||||
control's presenter instead of element handles: owners write it by part
|
||||
(`update('detail', { text })`) and listen with `on('button', fn)`, and the
|
||||
command dock renders it from a store. `createVoiceCommands`' `createControl`
|
||||
and `VoiceCardControls` (`{ ui }` instead of `{ root }`) take the same
|
||||
presenter; the `reset` option is gone, since a newer control replaces the
|
||||
previous one (samehkhamis).
|
||||
|
||||
- `./scenes` presents the scene sharing dialogs and the EDIT DETAILS / SHARE
|
||||
SCENE bar through a store that the scene panels view renders, so a page
|
||||
that omits `layer-panels` shows neither (samehkhamis).
|
||||
|
||||
@@ -297,6 +297,8 @@
|
||||
"src/ui/styles.js",
|
||||
"src/ui/shellLifecycle.test.mjs",
|
||||
"src/testSupport/readStylesheet.mjs",
|
||||
"src/testSupport/voiceUi.mjs",
|
||||
"src/testSupport/voiceView.mjs",
|
||||
"src/testSupport/streetLevelFakes.mjs",
|
||||
"src/splitFlap.js",
|
||||
"style.css",
|
||||
@@ -1102,12 +1104,13 @@
|
||||
"src/hudSummaryGate.test.mjs",
|
||||
"src/voice/hudPlaceLabels.test.mjs",
|
||||
"src/voice/panel/preferences.test.mjs",
|
||||
"src/voice/panel/preferencesView.test.mjs",
|
||||
"src/voice/voiceControlView.test.mjs",
|
||||
"src/voice/panel/host.test.mjs",
|
||||
"src/voice/panel/manualPanel.test.mjs",
|
||||
"src/voice/panel/nativeActivity.test.mjs",
|
||||
"src/voice/panel/inactivity.test.mjs",
|
||||
"src/voice/panel/actionObserver.test.mjs",
|
||||
"src/voice/panel/testDom.mjs",
|
||||
"src/voice/realtimeCostPanel.test.mjs",
|
||||
"docs/SHARE-LINK-FORMAT.md",
|
||||
"src/share/wire.test.mjs",
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
"src/ui/views/panels/RadioTuner.vue",
|
||||
"src/ui/views/panels/LocalePicker.vue",
|
||||
"src/ui/views/panels/SceneDialog.vue",
|
||||
"src/ui/views/panels/VoiceControl.vue",
|
||||
"src/ui/views/panels/VoiceCard.vue",
|
||||
"src/ui/views/panels/VoiceVisualizer.vue",
|
||||
"src/ui/localePicker.js",
|
||||
"src/ui/views/panels/PhoneTabBar.vue",
|
||||
"src/ui/views/panels/CockpitBriefing.vue",
|
||||
@@ -78,6 +81,7 @@
|
||||
"src/ui/stores/locale.js",
|
||||
"src/ui/stores/providerSettings.js",
|
||||
"src/ui/stores/sceneSharing.js",
|
||||
"src/ui/stores/voice.js",
|
||||
"src/ui/stores/location.js",
|
||||
"src/ui/stores/streetLevel.js",
|
||||
"src/ui/rail/RailChips.vue",
|
||||
@@ -87,7 +91,8 @@
|
||||
"src/ui/rail/classToggle.js",
|
||||
"src/ui/rail/focusSafeChildren.js",
|
||||
"src/ui/rail/fieldValue.js",
|
||||
"src/ui/rail/statusText.js"
|
||||
"src/ui/rail/statusText.js",
|
||||
"src/voice/control.js"
|
||||
],
|
||||
"external": ["vue", "pinia"]
|
||||
},
|
||||
@@ -1003,6 +1008,7 @@
|
||||
"src/ui/stores/locale.js",
|
||||
"src/ui/stores/providerSettings.js",
|
||||
"src/ui/stores/sceneSharing.js",
|
||||
"src/ui/stores/voice.js",
|
||||
"src/ui/stores/location.js",
|
||||
"src/ui/stores/display.js"
|
||||
],
|
||||
@@ -2042,9 +2048,11 @@
|
||||
"src/voice/geminiCaptureWorklet.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/requestDeadline.js",
|
||||
"src/i18n/marker.js"
|
||||
"src/i18n/marker.js",
|
||||
"src/ui/stores/voice.js",
|
||||
"src/ui/vueRuntime.js"
|
||||
],
|
||||
"external": []
|
||||
"external": ["vue", "pinia"]
|
||||
},
|
||||
"application-components": {
|
||||
"exports": [
|
||||
@@ -2982,6 +2990,7 @@
|
||||
"src/ui/stores/locale.js",
|
||||
"src/ui/stores/providerSettings.js",
|
||||
"src/ui/stores/sceneSharing.js",
|
||||
"src/ui/stores/voice.js",
|
||||
"src/ui/stores/location.js",
|
||||
"src/ui/stores/display.js"
|
||||
],
|
||||
@@ -3944,9 +3953,11 @@
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/statusText.js",
|
||||
"src/voice/providerCapabilities.js"
|
||||
"src/voice/providerCapabilities.js",
|
||||
"src/ui/vueRuntime.js",
|
||||
"src/ui/stores/voice.js"
|
||||
],
|
||||
"external": []
|
||||
"external": ["vue", "pinia"]
|
||||
},
|
||||
"alpr-source": {
|
||||
"exports": ["./layers/alpr/source"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { markRaw, shallowRef } from 'vue';
|
||||
|
||||
/**
|
||||
* The voice control as the command dock renders it. The voice control
|
||||
* presenter (src/voice/control.js) holds the control's state for the session
|
||||
* owners and publishes it here; the view sends clicks, keys and form changes
|
||||
* back to the connected presenter.
|
||||
*/
|
||||
export const useVoiceStore = defineStore('gev-voice', () => {
|
||||
/** The presented control; null while no voice session owns one. */
|
||||
const view = shallowRef(null);
|
||||
|
||||
/** @param {?object} next A fresh presentation; every publish re-renders. */
|
||||
function publish(next) {
|
||||
view.value = next && markRaw(next);
|
||||
}
|
||||
|
||||
/** The presenter the view calls, or null while none is wired. */
|
||||
const controls = shallowRef(null);
|
||||
|
||||
/** @param {?object} next The presenter, or null to disconnect. */
|
||||
function connect(next) {
|
||||
controls.value = next && markRaw(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* The waveform's bars, apart from the view: they change every animation
|
||||
* frame while voice is live, and only the visualizer renders them.
|
||||
*/
|
||||
const bars = shallowRef(null);
|
||||
|
||||
/** @param {?Array<{level: string, opacity: string}>} next */
|
||||
function publishBars(next) {
|
||||
bars.value = next && markRaw(next);
|
||||
}
|
||||
|
||||
return { view, publish, controls, connect, bars, publishBars };
|
||||
});
|
||||
@@ -3,8 +3,11 @@ import { storeToRefs } from 'pinia';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { vClassToggle } from '../rail/classToggle.js';
|
||||
import { useDisplayStore } from '../stores/display.js';
|
||||
import { computed } from 'vue';
|
||||
import { useLocationStore } from '../stores/location.js';
|
||||
import { useVoiceStore } from '../stores/voice.js';
|
||||
import PhoneTabBar from './panels/PhoneTabBar.vue';
|
||||
import VoiceControl from './panels/VoiceControl.vue';
|
||||
|
||||
// VisualSettings publishes the active style and the map source controls
|
||||
// their chip row; until they do, the dock shows its initial markup. The
|
||||
@@ -14,6 +17,21 @@ const { style, mapSource, mapSourceControls } = storeToRefs(useDisplayStore());
|
||||
// and the search field keeps their listeners.
|
||||
const { view: loc, controls: locationControls } = storeToRefs(useLocationStore());
|
||||
|
||||
// The voice control, while a voice session presents one. It sits between the
|
||||
// location bar and the visual presets; the trays keep their elements when the
|
||||
// order changes.
|
||||
const { view: voice } = storeToRefs(useVoiceStore());
|
||||
// Once voice has taken its place, the location bar stays first.
|
||||
let voiceShown = false;
|
||||
const dockOrder = computed(() => {
|
||||
if (voice.value) voiceShown = true;
|
||||
return voice.value
|
||||
? ['location', 'voice', 'control']
|
||||
: voiceShown
|
||||
? ['location', 'control']
|
||||
: ['control', 'location'];
|
||||
});
|
||||
|
||||
function selectMapSource(chip) {
|
||||
if (!chip.available) return;
|
||||
void mapSourceControls.value?.select(chip.id).catch(() => {});
|
||||
@@ -21,10 +39,11 @@ function selectMapSource(chip) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Unified bottom command dock: locations, voice (injected at runtime), visual presets -->
|
||||
<!-- Unified bottom command dock: locations, voice, visual presets -->
|
||||
<div id="command-dock" class="ui-scroll" aria-label="Navigation, voice, and visual preset controls" data-i18n-aria-label>
|
||||
<template v-for="slot in dockOrder" :key="slot">
|
||||
<!-- Control panel -->
|
||||
<div id="control-panel" class="panel-collapsible collapsed">
|
||||
<div v-if="slot === 'control'" id="control-panel" class="panel-collapsible collapsed">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="panel-inner">
|
||||
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
|
||||
@@ -82,8 +101,10 @@ function selectMapSource(chip) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VoiceControl v-else-if="slot === 'voice'" :key="voice.id" :view="voice" />
|
||||
|
||||
<!-- Location bar -->
|
||||
<div id="location-bar" class="panel-collapsible collapsed">
|
||||
<div v-else-if="slot === 'location'" id="location-bar" class="panel-collapsible collapsed">
|
||||
<div class="panel-glow"></div>
|
||||
<div class="location-inner">
|
||||
<div class="location-toolbar">
|
||||
@@ -113,6 +134,7 @@ function selectMapSource(chip) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<PhoneTabBar />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import { ASSISTANT_NAME } from '../../../voice/control.js';
|
||||
|
||||
// Captions, plan and result, as VoiceCardControls (src/voice/voiceCard.js)
|
||||
// presents them. Captions update silently; the polite live region carries
|
||||
// final replies and result titles only.
|
||||
defineProps({ card: { type: Object, required: true } });
|
||||
const emit = defineEmits(['dismiss']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="gev-voice-card" class="gev-voice-card ui-scroll" :hidden="!card.visible" :data-busy="card.busy == null ? undefined : String(card.busy)" :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">{{ card.phase }}</span>
|
||||
<button id="gev-voice-card-dismiss" class="gev-voice-card-dismiss" type="button" :aria-label="t('Dismiss voice card')" @click="emit('dismiss')">×</button>
|
||||
</div>
|
||||
<p id="gev-voice-card-user" class="gev-voice-card-caption" data-role="user" :hidden="!card.user.visible"><span id="gev-voice-card-user-who" class="gev-voice-card-who">{{ card.user.label }}</span><span id="gev-voice-card-user-text" class="gev-voice-card-text">{{ card.user.text }}</span></p>
|
||||
<p id="gev-voice-card-pointer" class="gev-voice-card-pointer" :hidden="!card.pointer.visible"><span id="gev-voice-card-pointer-kind" class="gev-voice-card-who">{{ card.pointer.kind }}</span><span class="gev-voice-card-sr">: </span><span id="gev-voice-card-pointer-text" class="gev-voice-card-pointer-label">{{ card.pointer.text }}</span></p>
|
||||
<p id="gev-voice-card-assistant" class="gev-voice-card-caption" data-role="assistant" :hidden="!card.assistant.visible"><span class="gev-voice-card-who">{{ ASSISTANT_NAME }}</span><span id="gev-voice-card-assistant-text" class="gev-voice-card-text">{{ card.assistant.text }}</span></p>
|
||||
<ol id="gev-voice-card-plan" class="gev-voice-card-plan" :aria-label="t('Plan')" :hidden="card.steps.length === 0"><li v-for="(step, index) in card.steps" :key="step.key ?? index" class="gev-voice-card-step" :data-status="step.status"><span class="gev-voice-card-step-label">{{ step.label }}</span><span class="gev-voice-card-sr">{{ step.statusText ? `, ${step.statusText}` : '' }}</span><span v-if="step.detail" class="gev-voice-card-step-detail">{{ step.detail }}</span></li></ol>
|
||||
<div id="gev-voice-card-result" class="gev-voice-card-result" :hidden="!card.result.visible">
|
||||
<div id="gev-voice-card-result-title" class="gev-voice-card-result-title">{{ card.result.title }}</div>
|
||||
<div id="gev-voice-card-chips" class="gev-voice-card-chips" :hidden="card.result.chips.length === 0"><span v-for="(chip, index) in card.result.chips" :key="index" class="gev-voice-card-chip" :data-tone="chip">{{ chip }}</span></div>
|
||||
<ul id="gev-voice-card-lines" class="gev-voice-card-lines"><li v-for="(line, index) in card.result.lines" :key="index">{{ line }}</li></ul>
|
||||
<ol id="gev-voice-card-referents" class="gev-voice-card-referents" :aria-label="t('Referenced items')" :hidden="card.result.referents.length === 0"><li v-for="(ref, index) in card.result.referents" :key="index" :value="ref.n">{{ ref.label }}</li></ol>
|
||||
<details id="gev-voice-card-notes" class="gev-voice-card-notes" :hidden="card.result.notes.length === 0">
|
||||
<summary>{{ t('NOTES & SOURCES') }}</summary>
|
||||
<ul id="gev-voice-card-note-list" class="gev-voice-card-lines"><li v-for="(note, index) in card.result.notes" :key="index">{{ note }}</li></ul>
|
||||
</details>
|
||||
</div>
|
||||
<div id="gev-voice-card-live" class="gev-voice-card-sr" aria-live="polite" aria-atomic="true">{{ card.announce }}</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import { vClassToggle } from '../../rail/classToggle.js';
|
||||
import { useVoiceStore } from '../../stores/voice.js';
|
||||
import VoiceCard from './VoiceCard.vue';
|
||||
import VoiceVisualizer from './VoiceVisualizer.vue';
|
||||
|
||||
// The voice control in the command dock, as its presenter
|
||||
// (src/voice/control.js) holds it. The session owners write the presenter;
|
||||
// this renders it and reports what the person does.
|
||||
defineProps({ view: { type: Object, required: true } });
|
||||
const { controls } = storeToRefs(useVoiceStore());
|
||||
const emit = (name, ...args) => controls.value?.emit(name, ...args);
|
||||
|
||||
// A value the owner may restore after a refused change; written on every
|
||||
// render, as the owner's own writes were.
|
||||
const vChecked = {
|
||||
mounted(el, { value }) {
|
||||
el.checked = value;
|
||||
},
|
||||
updated(el, { value }) {
|
||||
if (el.checked !== value) el.checked = value;
|
||||
},
|
||||
};
|
||||
// The minutes field holds a draft until it changes: write it only when its
|
||||
// owner does, not on every render (the waveform renders each frame).
|
||||
const vOwnedValue = {
|
||||
mounted(el, { value }) {
|
||||
el.value = value.text;
|
||||
},
|
||||
updated(el, { value, oldValue }) {
|
||||
if (value.revision !== oldValue?.revision && el.value !== value.text)
|
||||
el.value = value.text;
|
||||
},
|
||||
};
|
||||
const optional = (value) => (value == null ? undefined : String(value));
|
||||
// The root's machine-state attributes, which the dock and theme CSS select on.
|
||||
const rootData = (root) => ({
|
||||
'data-status': root.status,
|
||||
'data-speaker': root.speaker,
|
||||
'data-provider': optional(root.provider),
|
||||
'data-provider-choice': optional(root.providerChoice),
|
||||
'data-push-to-talk': optional(root.pushToTalk),
|
||||
'data-microphone': optional(root.microphone),
|
||||
'data-row-grace': root.rowGrace ? 'true' : undefined,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="gev-voice-control" v-bind="rootData(view.root)" v-class-toggle="{ 'error-dismissed': view.root.errorDismissed }" @pointerleave="emit('pointerleave', $event)" @pointerenter="emit('pointerenter', $event)">
|
||||
<label id="gev-voice-provider-field" class="gev-voice-provider-field" :hidden="view.provider.hidden">
|
||||
<span>{{ t('Voice provider') }}</span>
|
||||
<select id="gev-voice-provider" class="gev-field" :aria-label="t('Voice provider')" :title="view.provider.title" :value="view.provider.value" @change="emit('provider-change', $event.target.value)"><option v-for="option in view.provider.options" :key="option.value" :value="option.value">{{ option.label }}</option></select>
|
||||
</label>
|
||||
<button v-if="view.panel" id="gev-voice-settings-button" class="gev-voice-settings-button" type="button" :aria-label="t('Voice Settings')" aria-controls="gev-voice-panel" :aria-expanded="String(view.voicePanel.settingsExpanded)" @click="emit('settings-button')"><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">{{ view.status.text }}</div>
|
||||
<span v-if="view.panel" id="gev-voice-cost-warning" class="gev-voice-cost-value gev-voice-cost-warning" :hidden="view.costWarning.hidden" :title="optional(view.costWarning.title)" :data-level="optional(view.costWarning.level)" role="status" aria-live="polite">{{ view.costWarning.text }}</span>
|
||||
<div v-else class="gev-voice-cost">
|
||||
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" :hidden="view.tier.hidden" :aria-pressed="String(view.tier.pressed)" :title="view.tier.title" @click="emit('tier')">{{ view.tier.text }}</button>
|
||||
<span id="gev-voice-cost-value" class="gev-voice-cost-value" :hidden="view.cost.hidden" :data-level="view.cost.level" :title="view.cost.title">{{ view.cost.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button id="gev-voice-button" type="button" :aria-label="view.button.label" :aria-pressed="optional(view.button.pressed)" aria-describedby="gev-voice-help" @click="emit('button')">
|
||||
<span class="gev-mic-orbit"><img src="/mic.svg" alt="" /></span>
|
||||
<span class="gev-mic-label">{{ view.mic.text }}</span>
|
||||
</button>
|
||||
<VoiceVisualizer />
|
||||
<div class="gev-voice-readout">
|
||||
<div id="gev-voice-detail" :title="optional(view.detail.title)">{{ view.detail.text }}</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">{{ view.help.text }}</span>
|
||||
</div>
|
||||
<section v-if="view.panel" id="gev-voice-panel" class="gev-voice-panel" :hidden="view.voicePanel.hidden" :data-view="optional(view.voicePanel.view)" :aria-label="t('Voice Settings')" @keydown="emit('panel-keydown', $event)">
|
||||
<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="String(view.voicePanel.selected === 'preview')" :tabindex="view.voicePanel.selected === 'preview' ? 0 : -1" aria-controls="gev-voice-preview-panel" @click="emit('tab', 0)" @keydown="emit('tab-keydown', 0, $event)"><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="String(view.voicePanel.selected === 'settings')" :tabindex="view.voicePanel.selected === 'settings' ? 0 : -1" aria-controls="gev-voice-settings-panel" @click="emit('tab', 1)" @keydown="emit('tab-keydown', 1, $event)"><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')" @click="emit('settings-close')">×</button>
|
||||
</div>
|
||||
<div class="gev-voice-panel-body ui-scroll">
|
||||
<section id="gev-voice-preview-panel" role="tabpanel" aria-labelledby="gev-voice-preview-tab" :hidden="view.voicePanel.previewHidden">
|
||||
<p id="gev-voice-preview-empty" :hidden="view.previewEmpty.hidden">{{ view.previewEmpty.text }}</p>
|
||||
<VoiceCard :card="view.card" @dismiss="emit('card-dismiss')" />
|
||||
</section>
|
||||
<section id="gev-voice-settings-panel" role="tabpanel" aria-labelledby="gev-voice-settings-tab" :hidden="view.voicePanel.settingsHidden">
|
||||
<label class="gev-voice-preference gev-voice-preference-toggle"><input id="gev-voice-preview-enabled" type="checkbox" aria-describedby="gev-voice-preview-note" v-checked="view.preferences.previewChecked" @change="emit('preview-change', $event.target.checked)" /><span>{{ t('Show Voice Preview') }}</span></label>
|
||||
<p id="gev-voice-preview-note" class="gev-voice-settings-note">{{ view.preferences.previewNote }}</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" :value="view.preferences.timeoutChoice" @change="emit('timeout-select', $event.target.value)">
|
||||
<option value="3">3 {{ t('Minutes') }}</option><option value="5">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" aria-describedby="gev-voice-timeout-note" v-owned-value="{ text: view.preferences.timeoutMinutes, revision: view.preferences.revision }" :hidden="view.preferences.timeoutInputHidden" @change="emit('timeout-input', $event.target.value)" />
|
||||
</div>
|
||||
<p id="gev-voice-timeout-note" class="gev-voice-settings-note">{{ view.preferences.timeoutNote }}</p>
|
||||
<p id="gev-voice-timeout-status" class="gev-voice-settings-note" role="status" aria-live="polite">{{ view.preferences.timeoutStatus }}</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" :hidden="view.tier.hidden" :aria-pressed="String(view.tier.pressed)" :title="view.tier.title" @click="emit('tier')">{{ view.tier.text }}</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" :hidden="view.cost.hidden" :data-level="view.cost.level" :title="view.cost.title">{{ view.cost.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<VoiceCard v-else :card="view.card" @dismiss="emit('card-dismiss')" />
|
||||
<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" @click="controls?.dismissError()">{{ t('DISMISS') }}</button>
|
||||
</div>
|
||||
<div id="gev-voice-error-detail">{{ view.error.text }}</div>
|
||||
<div class="gev-voice-error-hint">{{ t('Check microphone permission and network access, then try again.') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { VOICE_VISUALIZER_BARS } from '../../../voice/control.js';
|
||||
import { useVoiceStore } from '../../stores/voice.js';
|
||||
|
||||
// The dock waveform. Its bars change every animation frame while voice is
|
||||
// live, so they render here alone, not with the rest of the control.
|
||||
const { bars } = storeToRefs(useVoiceStore());
|
||||
const barStyle = (index) => ({
|
||||
'--bar': index,
|
||||
...(bars.value?.[index] && {
|
||||
'--audio-level': bars.value[index].level,
|
||||
'--audio-opacity': bars.value[index].opacity,
|
||||
}),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gev-voice-visualizer" aria-hidden="true">
|
||||
<span v-for="index in VOICE_VISUALIZER_BARS" :key="index" :style="barStyle(index - 1)"></span>
|
||||
</div>
|
||||
</template>
|
||||
+248
-169
@@ -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;
|
||||
}
|
||||
@@ -2527,7 +2527,7 @@ export class GeminiSessionController {
|
||||
this.input.detachBindings();
|
||||
this.radio.detachObservers();
|
||||
this.signal?.removeEventListener('abort', this.lifetimeAbort);
|
||||
this.ui?.root?.remove();
|
||||
this.ui?.remove();
|
||||
this.emit({ type: 'disposed' });
|
||||
} else if (!preserveStatus) this.setStatus('idle', msg('Voice off'));
|
||||
}
|
||||
|
||||
+47
-212
@@ -34,6 +34,7 @@ import {
|
||||
withToolCatalog,
|
||||
} from './gevRealtime.js';
|
||||
import { createVoiceCostTracker } from './voiceCost.js';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
|
||||
test('push-to-talk recognizes Space by code or key', () => {
|
||||
assert.equal(PUSH_TO_TALK_HOLD_DELAY_MS, 500);
|
||||
@@ -114,10 +115,7 @@ function createPushToTalkFixture(t) {
|
||||
const makeController = () => {
|
||||
const microphone = { enabled: false, stopped: false, stop() { this.stopped = true; } };
|
||||
const controller = new GevRealtimeController({
|
||||
ui: {
|
||||
root: { dataset: {}, querySelectorAll: () => [], remove() {} },
|
||||
status: {}, detail: {},
|
||||
},
|
||||
ui: createTestVoiceUi(),
|
||||
runner: async () => ({}),
|
||||
radioLayer: {
|
||||
pause: () => radio.push('pause'),
|
||||
@@ -318,8 +316,8 @@ test('push-to-talk starts once at 500ms and repeat does not reset the threshold'
|
||||
assert.equal(f.controller.spaceKeyHeld, false);
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, false);
|
||||
assert.equal(f.microphone.enabled, false);
|
||||
assert.equal(f.controller.ui.root.dataset.pushToTalk, undefined);
|
||||
assert.equal(f.controller.ui.detail.textContent, 'Hold Space to talk');
|
||||
assert.equal(f.controller.ui.state.root.pushToTalk, null);
|
||||
assert.equal(f.controller.ui.state.detail.text, 'Hold Space to talk');
|
||||
});
|
||||
|
||||
test('push-to-talk treats a Space release before 500ms as a background tap', (t) => {
|
||||
@@ -392,7 +390,7 @@ test('push-to-talk cancels pending and active holds on blur or hidden document',
|
||||
assert.equal(f.controller.spaceKeyHeld, false);
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, false);
|
||||
assert.equal(f.microphone.enabled, false);
|
||||
assert.equal(f.controller.ui.root.dataset.pushToTalk, undefined);
|
||||
assert.equal(f.controller.ui.state.root.pushToTalk, null);
|
||||
assert.equal(f.key('keyup', background).defaultPrevented, false);
|
||||
f.document.visibilityState = 'visible';
|
||||
}
|
||||
@@ -579,7 +577,7 @@ test('failed or superseded Radio preflight keeps voice open and cancels audio',
|
||||
test('replacement input invalidates an in-flight Radio preflight immediately', () => {
|
||||
const order = [];
|
||||
const controller = new GevRealtimeController({
|
||||
ui: {},
|
||||
ui: createTestVoiceUi(),
|
||||
runner: async () => ({}),
|
||||
radioLayer: { stopPlayback: () => order.push('radio-stop') },
|
||||
});
|
||||
@@ -596,12 +594,7 @@ test('replacement input invalidates an in-flight Radio preflight immediately', (
|
||||
test('direct Radio pause or stop cancels a pending voice handoff', async () => {
|
||||
const order = [];
|
||||
let playbackControl = null;
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
runner: async () => ({ ok: true }),
|
||||
@@ -637,12 +630,7 @@ test('direct Radio pause or stop cancels a pending voice handoff', async () => {
|
||||
test('confirmed manual Radio playback closes active voice without stopping Radio', () => {
|
||||
const order = [];
|
||||
let playbackControl = null;
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
runner: async () => ({ ok: true }),
|
||||
@@ -675,12 +663,7 @@ test('manual playback takeover survives stale voice preflight cleanup', async ()
|
||||
let resolvePreflight;
|
||||
let activeAttemptId = null;
|
||||
const stopRequests = [];
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const radioLayer = {
|
||||
subscribePlaybackControls(listener) {
|
||||
playbackControl = listener;
|
||||
@@ -732,7 +715,7 @@ test('manual playback takeover survives stale voice preflight cleanup', async ()
|
||||
test('internal Radio cleanup controls do not masquerade as newer user input', () => {
|
||||
let playbackControl = null;
|
||||
const controller = new GevRealtimeController({
|
||||
ui: {},
|
||||
ui: createTestVoiceUi(),
|
||||
runner: async () => ({}),
|
||||
radioLayer: {
|
||||
subscribePlaybackControls(listener) {
|
||||
@@ -757,7 +740,7 @@ test('manual Radio playback does not close voice when voice is already idle', ()
|
||||
let playbackControl = null;
|
||||
let voiceStops = 0;
|
||||
const controller = new GevRealtimeController({
|
||||
ui: {},
|
||||
ui: createTestVoiceUi(),
|
||||
runner: async () => ({}),
|
||||
radioLayer: {
|
||||
subscribePlaybackControls(listener) {
|
||||
@@ -777,12 +760,7 @@ test('manual Radio playback does not close voice when voice is already idle', ()
|
||||
test('active-response Realtime errors invalidate pending and in-flight Radio handoffs', async () => {
|
||||
const order = [];
|
||||
let playbackControl = null;
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
runner: async () => ({ ok: true }),
|
||||
@@ -830,16 +808,7 @@ test('voice ownership ducks tuner static before pausing broadcaster audio', () =
|
||||
test('Radio handoff waits for response.done so later multi-intent tools execute before voice closes', async () => {
|
||||
const order = [];
|
||||
const confirmationInstructions = [];
|
||||
const ui = {
|
||||
root: {
|
||||
dataset: {},
|
||||
classList: { remove() {} },
|
||||
querySelectorAll: () => [],
|
||||
},
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const runner = async (name) => {
|
||||
order.push(`tool:${name}`);
|
||||
if (name === 'control_radio') {
|
||||
@@ -924,12 +893,7 @@ test('Radio handoff waits for response.done so later multi-intent tools execute
|
||||
test('Radio playback failure leaves voice connected and speaks a correction', async () => {
|
||||
const sent = [];
|
||||
const order = [];
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
runner: async () => ({
|
||||
@@ -982,16 +946,7 @@ test('Radio playback failure leaves voice connected and speaks a correction', as
|
||||
|
||||
test('detected speech cancels a prepared Radio handoff before a cancelled response completes', async () => {
|
||||
const order = [];
|
||||
const ui = {
|
||||
root: {
|
||||
dataset: {},
|
||||
classList: { remove() {} },
|
||||
querySelectorAll: () => [],
|
||||
},
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
runner: async () => ({
|
||||
@@ -1050,12 +1005,7 @@ test('a Radio tool result that resolves after speech interruption cannot re-arm
|
||||
let resolveRunner;
|
||||
const runnerResult = new Promise((resolve) => { resolveRunner = resolve; });
|
||||
let receivedSignal = null;
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
runner: async (_name, _args, options) => {
|
||||
@@ -1110,12 +1060,7 @@ test('sibling tool calls in one response do not abort an in-flight Radio action'
|
||||
let resolveRadio;
|
||||
let radioSignal = null;
|
||||
const radioResult = new Promise((resolve) => { resolveRadio = resolve; });
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
runner: async (name, _args, options) => {
|
||||
@@ -1156,12 +1101,7 @@ test('Radio stop does not abort an unrelated sibling tool from the same response
|
||||
let releaseStyle;
|
||||
let styleSignal = null;
|
||||
const styleResult = new Promise((resolve) => { releaseStyle = resolve; });
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
runner: async (name, _args, options) => {
|
||||
@@ -1235,12 +1175,7 @@ test('real Radio Select cannot enable or play after a same-response Disable comp
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
globalThis.window = { __GOOGLE_MAPS_API_KEY__: 'test-key' };
|
||||
globalThis.fetch = async () => {
|
||||
announceLookupStarted();
|
||||
@@ -1366,12 +1301,7 @@ test('generic same-response Radio visibility disable supersedes delayed Select',
|
||||
camera: { moveEnd: { addEventListener() {} } },
|
||||
};
|
||||
const genericRunner = createGevActionRunner({ viewer, styleManager: {}, dataManager });
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
globalThis.window = { __GOOGLE_MAPS_API_KEY__: 'test-key' };
|
||||
globalThis.fetch = async () => {
|
||||
announceLookupStarted();
|
||||
@@ -1501,12 +1431,7 @@ test('same-response Pause aborts a real manager enable already in flight', async
|
||||
dataManager.subscribe((change) => {
|
||||
if (change.type === 'visibility') trace.push(`visibility:${change.enabled}`);
|
||||
});
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
dataManager,
|
||||
@@ -1632,12 +1557,7 @@ test('Pause and Stop preserve independent dedicated and generic Radio ON across
|
||||
styleManager: {},
|
||||
dataManager,
|
||||
});
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
dataManager,
|
||||
@@ -1766,12 +1686,7 @@ test('successful same- or newer-response Stop aborts Select across real manager
|
||||
if (change.origin === 'voice') persistenceWrites.push(change.enabled);
|
||||
}
|
||||
});
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
radioLayer,
|
||||
@@ -1837,12 +1752,7 @@ test('same-response pause and disable suppress play/select handoffs without canc
|
||||
const playbackGate = new Promise((resolve) => { releasePlayback = resolve; });
|
||||
const sent = [];
|
||||
const radioState = { enabled: true, audioState: 'playing' };
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
radioLayer: { getUIState: () => ({ ...radioState }) },
|
||||
@@ -1918,12 +1828,7 @@ test('Realtime Radio route exceptions preserve the authoritative lifecycle summa
|
||||
for (const route of ['dedicated', 'generic']) {
|
||||
const sent = [];
|
||||
const lifecycle = { enabled: true, lifecycleState: 'disabling', uncertain: true };
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
dataManager: {
|
||||
@@ -1970,12 +1875,7 @@ test('different-response Radio stop remains a cancellation authority for an olde
|
||||
let releaseSelect;
|
||||
const selectGate = new Promise((resolve) => { releaseSelect = resolve; });
|
||||
const sent = [];
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
runner: async (_name, args) => {
|
||||
@@ -2048,12 +1948,7 @@ test('failed same-response stop does not suppress a successful playback request'
|
||||
}]]),
|
||||
isEnabled: () => true,
|
||||
};
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
runner: async (_name, args) => {
|
||||
@@ -2156,12 +2051,7 @@ test('failed same-response Stop preserves Select auto-enable held inside real ma
|
||||
};
|
||||
dataManager = new DataLayerManager({});
|
||||
dataManager.register(radioLayer);
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
dataManager,
|
||||
@@ -2244,12 +2134,7 @@ test('failed same-response Pause and Disable do not suppress valid older playbac
|
||||
return () => visibilityListeners.delete(listener);
|
||||
},
|
||||
};
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
dataManager,
|
||||
@@ -2307,12 +2192,7 @@ test('failed generic same-response Radio OFF does not suppress valid older playb
|
||||
let releaseSelect;
|
||||
const selectGate = new Promise((resolve) => { releaseSelect = resolve; });
|
||||
const sent = [];
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
runner: async (name, args) => {
|
||||
@@ -2401,12 +2281,7 @@ test('direct user Radio OFF aborts an in-flight voice enable before settled publ
|
||||
dataManager.subscribe((change) => {
|
||||
if (change.type === 'visibility') visibility.push(`${change.origin}:${change.enabled}`);
|
||||
});
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
dataManager,
|
||||
@@ -2492,12 +2367,7 @@ test('direct user Radio OFF freezes a prepared handoff until disable success or
|
||||
dataManager = new DataLayerManager({});
|
||||
dataManager.register(radioLayer);
|
||||
await dataManager.setEnabled('radio', true);
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
dataManager,
|
||||
@@ -2571,12 +2441,7 @@ test('dedicated and generic Radio OFF reservations freeze an in-flight playback
|
||||
},
|
||||
getUIState: () => ({ enabled: true, audioState: 'stopped' }),
|
||||
};
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
radioLayer,
|
||||
@@ -2673,12 +2538,7 @@ test('delayed Stop reports its result before committing or resuming a prepared h
|
||||
},
|
||||
getUIState: () => ({ enabled: true, audioState: 'stopped' }),
|
||||
};
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
radioLayer,
|
||||
@@ -2756,12 +2616,7 @@ test('stale handoff cleanup cannot erase a resumed successor across repeated fai
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
radioLayer,
|
||||
@@ -2808,12 +2663,7 @@ test('stale handoff cleanup cannot erase a resumed successor across repeated fai
|
||||
});
|
||||
|
||||
test('a later same-response stop clears an already prepared playback result', async () => {
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
runner: async (_name, args) => args.action === 'select'
|
||||
@@ -3107,22 +2957,7 @@ const usdUsage = (usd) => ({
|
||||
/** A controller wired to inert UI stubs, with the cost surface present. */
|
||||
function costControllerHarness({ runner } = {}) {
|
||||
const toolCalls = [];
|
||||
const ui = {
|
||||
root: { dataset: {}, classList: { remove() {}, add() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
button: { dataset: {} },
|
||||
buttonLabel: { textContent: '' },
|
||||
tierButton: {
|
||||
textContent: '',
|
||||
title: '',
|
||||
attrs: {},
|
||||
setAttribute(key, value) { this.attrs[key] = value; },
|
||||
getAttribute(key) { return this.attrs[key] ?? null; },
|
||||
},
|
||||
costValue: { textContent: '', title: '', dataset: {} },
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const controller = new GevRealtimeController({
|
||||
ui,
|
||||
runner: runner || (async (name) => { toolCalls.push(name); return { ok: true }; }),
|
||||
@@ -3190,9 +3025,9 @@ test('F1: the toggle still records the next-session preference while live', () =
|
||||
controller.status = 'listening';
|
||||
controller.setVoiceTier('mini');
|
||||
assert.equal(controller.voiceTier, 'mini');
|
||||
assert.equal(ui.tierButton.textContent, 'MINI');
|
||||
assert.equal(ui.state.tier.text, 'MINI');
|
||||
// ...and says so, rather than implying the live session switched.
|
||||
assert.match(ui.tierButton.title, /this session stays on/i);
|
||||
assert.match(ui.state.tier.title, /this session stays on/i);
|
||||
});
|
||||
|
||||
test('OAuth cloud sessions report unknown cost and usage without tripping the API spend guard', () => {
|
||||
@@ -3202,8 +3037,8 @@ test('OAuth cloud sessions report unknown cost and usage without tripping the AP
|
||||
controller._cost.sessionCloudVoiceAuth = 'oauth';
|
||||
controller.syncCostUi();
|
||||
const before = controller.costTracker.state();
|
||||
assert.equal(ui.costValue.hidden, false);
|
||||
assert.equal(ui.costValue.textContent, 'COST UNKNOWN');
|
||||
assert.equal(ui.state.cost.hidden, false);
|
||||
assert.equal(ui.state.cost.text, 'COST UNKNOWN');
|
||||
assert.deepEqual(controller.recordUsage(usdUsage(100)), {
|
||||
responses: 1,
|
||||
input: 0,
|
||||
@@ -3214,11 +3049,11 @@ test('OAuth cloud sessions report unknown cost and usage without tripping the AP
|
||||
const after = controller.costTracker.state();
|
||||
assert.equal(after.totalUsd, before.totalUsd);
|
||||
assert.equal(controller.costCapStopped, false);
|
||||
assert.match(ui.costValue.title, /1 response\(s\).*1562500 output tokens/);
|
||||
assert.match(ui.state.cost.title, /1 response\(s\).*1562500 output tokens/);
|
||||
controller.dc = {};
|
||||
controller.responseActive = true;
|
||||
controller.stop();
|
||||
assert.match(ui.costValue.title, /Usage is incomplete/);
|
||||
assert.match(ui.state.cost.title, /Usage is incomplete/);
|
||||
});
|
||||
|
||||
test('OAuth cloud sessions count caption transcriptions without pricing them into the API cap', () => {
|
||||
@@ -3239,8 +3074,8 @@ test('OAuth cloud sessions count caption transcriptions without pricing them int
|
||||
assert.equal(after.totalUsd, before.totalUsd);
|
||||
assert.equal(after.capReached, false);
|
||||
assert.equal(controller.costCapStopped, false);
|
||||
assert.equal(ui.costValue.textContent, 'COST UNKNOWN');
|
||||
assert.match(ui.costValue.title, /1 caption transcription\(s\)/);
|
||||
assert.equal(ui.state.cost.text, 'COST UNKNOWN');
|
||||
assert.match(ui.state.cost.title, /1 caption transcription\(s\)/);
|
||||
});
|
||||
|
||||
test('F1: when idle, toggling does re-price the preview meter', () => {
|
||||
@@ -3444,7 +3279,7 @@ test('F4: two clicks during a live session return to the original preference', (
|
||||
|
||||
controller.toggleVoiceTier();
|
||||
assert.equal(controller.voiceTier, 'standard', 'second click switches back');
|
||||
assert.equal(ui.tierButton.textContent, 'STD');
|
||||
assert.equal(ui.state.tier.text, 'STD');
|
||||
});
|
||||
|
||||
test('F4: the toggle alternates across many clicks mid-session', () => {
|
||||
@@ -3481,7 +3316,7 @@ test('F3: an unrecognised session model bills at the most expensive known rates'
|
||||
function textCommandController() {
|
||||
const sent = [];
|
||||
const controller = new GevRealtimeController({
|
||||
ui: {},
|
||||
ui: createTestVoiceUi(),
|
||||
runner: async () => ({}),
|
||||
});
|
||||
controller.dc = { readyState: 'open' };
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
import test from 'node:test';
|
||||
import { GevRealtimeController } from './realtimeController.js';
|
||||
import {
|
||||
@@ -16,7 +17,7 @@ function harness(result) {
|
||||
const sent = [];
|
||||
const calls = [];
|
||||
const controller = new GevRealtimeController({
|
||||
ui: {},
|
||||
ui: createTestVoiceUi(),
|
||||
runner: async (name, args, context) => {
|
||||
calls.push(name);
|
||||
return typeof result === 'function'
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Run with: npm test
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
import {
|
||||
initialVoiceCardState,
|
||||
reduceVoiceCard,
|
||||
@@ -206,14 +207,7 @@ test('the voice controls reset the runner on stop and dispose it on removal', as
|
||||
globalThis.window = {};
|
||||
try {
|
||||
const h = harness();
|
||||
const button = new EventTarget();
|
||||
button.setAttribute = () => {};
|
||||
const ui = {
|
||||
button,
|
||||
root: { dataset: {}, remove() {} },
|
||||
status: {},
|
||||
detail: {},
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const lifetime = new AbortController();
|
||||
let emitState;
|
||||
const controls = createVoiceCommands({
|
||||
|
||||
+13
-12
@@ -32,7 +32,7 @@ export function createVoicePanelHost({
|
||||
return observer;
|
||||
}
|
||||
function mount({ ui, card, session, observer, nativeProvider }) {
|
||||
if (disposed || !ui.voicePanel || !card?.setEnabled || !card?.setPresented)
|
||||
if (disposed || !ui.panel || !card?.setEnabled || !card?.setPresented)
|
||||
return null;
|
||||
current?.destroy();
|
||||
let dead = false;
|
||||
@@ -67,11 +67,12 @@ export function createVoicePanelHost({
|
||||
}
|
||||
};
|
||||
const renderActivity = () => {
|
||||
if (ui.voiceTimeoutStatus)
|
||||
ui.voiceTimeoutStatus.textContent =
|
||||
ui.update('preferences', {
|
||||
timeoutStatus:
|
||||
nativeProvider === 'openai' || nativeProvider === 'gemini'
|
||||
? ''
|
||||
: t('Automatic stop is unavailable for this voice connection.');
|
||||
: t('Automatic stop is unavailable for this voice connection.'),
|
||||
});
|
||||
};
|
||||
const changed = () => {
|
||||
if (!owns()) return;
|
||||
@@ -262,14 +263,14 @@ export function createVoicePanelHost({
|
||||
},
|
||||
{ signal: presentationLifetime.signal },
|
||||
);
|
||||
ui.root?.querySelector('#gev-voice-card-dismiss')?.addEventListener(
|
||||
'click',
|
||||
() => {
|
||||
remember();
|
||||
if (panel.snapshot().panelOpen) ui.previewTab?.focus();
|
||||
},
|
||||
{ signal: presentationLifetime.signal },
|
||||
);
|
||||
const offDismiss = ui.on('card-dismiss', () => {
|
||||
remember();
|
||||
if (panel.snapshot().panelOpen)
|
||||
void ui.focus(['previewTab'], { anyVisibility: true });
|
||||
});
|
||||
presentationLifetime.signal.addEventListener('abort', offDismiss, {
|
||||
once: true,
|
||||
});
|
||||
card.setEnabled(preferences.snapshot().previewEnabled);
|
||||
panel.sync();
|
||||
idle = createVoiceInactivity({
|
||||
|
||||
+161
-86
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export function createManualVoicePanel({
|
||||
copy,
|
||||
onPresentationIntent,
|
||||
}) {
|
||||
if (!ui?.voicePanel || typeof getPreviewEnabled !== 'function')
|
||||
if (!ui?.panel || typeof getPreviewEnabled !== 'function')
|
||||
throw new TypeError(
|
||||
'A voice panel and Preview preference reader are required.',
|
||||
);
|
||||
@@ -18,67 +18,52 @@ export function createManualVoicePanel({
|
||||
typeof copy?.previewDisabled !== 'string'
|
||||
)
|
||||
throw new TypeError('Localized Preview messages are required.');
|
||||
const lifetime = new AbortController();
|
||||
const panel = ui.voicePanel;
|
||||
const tabs = [ui.previewTab, ui.settingsTab];
|
||||
const doc = panel.ownerDocument;
|
||||
const offs = [];
|
||||
const tabs = ['previewTab', 'settingsTab'];
|
||||
const panes = { preview: 'previewPanel', settings: 'settingsPanel' };
|
||||
let disposed = false;
|
||||
let open = false;
|
||||
let view = 'preview';
|
||||
let graceTimer;
|
||||
const enabled = () => getPreviewEnabled() === true;
|
||||
const suppressed = () => view === 'preview' && getCockpitActive() === true;
|
||||
const visible = (element) =>
|
||||
Boolean(
|
||||
element &&
|
||||
!element.hidden &&
|
||||
element.isConnected !== false &&
|
||||
element.checkVisibility?.({
|
||||
opacityProperty: true,
|
||||
visibilityProperty: true,
|
||||
}) !== false,
|
||||
);
|
||||
const holdsFocus = (name) =>
|
||||
Boolean(ui.element(name)?.contains?.(globalThis.document?.activeElement));
|
||||
const snapshot = () => ({
|
||||
view,
|
||||
panelOpen: !disposed && open,
|
||||
settingsOpen: !disposed && open && view === 'settings',
|
||||
previewEnabled: enabled(),
|
||||
// The card as presented, on a control the page shows.
|
||||
previewVisible:
|
||||
!disposed &&
|
||||
open &&
|
||||
view === 'preview' &&
|
||||
!suppressed() &&
|
||||
enabled() &&
|
||||
visible(ui.card),
|
||||
ui.state.card.visible &&
|
||||
// The error tray takes the card's place.
|
||||
ui.state.root.status !== 'error' &&
|
||||
ui.visible('root'),
|
||||
});
|
||||
const sync = () => {
|
||||
if (disposed) return;
|
||||
const hidePanel = !open || suppressed();
|
||||
const restoreFocus = hidePanel && panel.contains?.(doc?.activeElement);
|
||||
panel.hidden = hidePanel;
|
||||
panel.dataset.view = view;
|
||||
if (ui.previewPanel)
|
||||
ui.previewPanel.hidden = !open || view !== 'preview' || suppressed();
|
||||
if (ui.voiceSettingsPanel)
|
||||
ui.voiceSettingsPanel.hidden = !open || view !== 'settings';
|
||||
ui.voiceSettingsButton?.setAttribute(
|
||||
'aria-expanded',
|
||||
String(open && view === 'settings'),
|
||||
);
|
||||
tabs.forEach((tab, index) => {
|
||||
if (!tab) return;
|
||||
const selected = view === (index === 0 ? 'preview' : 'settings');
|
||||
tab.setAttribute('aria-selected', String(selected));
|
||||
tab.tabIndex = selected ? 0 : -1;
|
||||
const restoreFocus = hidePanel && holdsFocus('voicePanel');
|
||||
ui.update('voicePanel', {
|
||||
hidden: hidePanel,
|
||||
view,
|
||||
previewHidden: !open || view !== 'preview' || suppressed(),
|
||||
settingsHidden: !open || view !== 'settings',
|
||||
settingsExpanded: open && view === 'settings',
|
||||
selected: view,
|
||||
});
|
||||
card?.setPresented?.(open && view === 'preview' && !suppressed());
|
||||
if (ui.previewEmpty) {
|
||||
ui.previewEmpty.hidden = Boolean(ui.card && !ui.card.hidden);
|
||||
ui.previewEmpty.textContent = enabled()
|
||||
? copy.previewEmpty
|
||||
: copy.previewDisabled;
|
||||
}
|
||||
if (restoreFocus)
|
||||
[ui.voiceSettingsButton, ui.button].find(visible)?.focus?.();
|
||||
ui.update('previewEmpty', {
|
||||
hidden: ui.state.card.visible,
|
||||
text: enabled() ? copy.previewEmpty : copy.previewDisabled,
|
||||
});
|
||||
if (restoreFocus) void ui.focus(['settingsButton', 'button']);
|
||||
};
|
||||
const refuse = (reason) => ({
|
||||
ok: false,
|
||||
@@ -90,16 +75,14 @@ export function createManualVoicePanel({
|
||||
if (disposed) return refuse('disposed');
|
||||
if (!['preview', 'settings'].includes(next)) return refuse('unknown-view');
|
||||
const changed = !open || view !== next;
|
||||
const outgoing =
|
||||
view === 'preview' ? ui.previewPanel : ui.voiceSettingsPanel;
|
||||
const transferFocus = changed && outgoing?.contains?.(doc?.activeElement);
|
||||
const transferFocus = changed && holdsFocus(panes[view]);
|
||||
open = true;
|
||||
view = next;
|
||||
onPresentationIntent?.({ automatic });
|
||||
sync();
|
||||
const tab = tabs[next === 'preview' ? 0 : 1];
|
||||
if ((focus || transferFocus) && visible(panel) && visible(tab))
|
||||
tab.focus?.();
|
||||
// The tab takes focus once it shows, inside a shown panel.
|
||||
if (focus || transferFocus)
|
||||
void ui.focus([tabs[next === 'preview' ? 0 : 1]]);
|
||||
return { ok: true, changed, ...snapshot() };
|
||||
};
|
||||
const close = (onlyView) => {
|
||||
@@ -114,58 +97,49 @@ export function createManualVoicePanel({
|
||||
sync();
|
||||
return { ok: true, changed, ...snapshot() };
|
||||
};
|
||||
const listen = (element, type, handler) =>
|
||||
element?.addEventListener(type, handler, { signal: lifetime.signal });
|
||||
const toggleSettings = () =>
|
||||
open && view === 'settings'
|
||||
? close('settings')
|
||||
: select('settings', { focus: true });
|
||||
try {
|
||||
listen(ui.voiceSettingsButton, 'click', toggleSettings);
|
||||
tabs.forEach((tab, index) => {
|
||||
listen(tab, 'click', () =>
|
||||
select(index === 0 ? 'preview' : 'settings', { focus: true }),
|
||||
);
|
||||
listen(tab, 'keydown', (event) => {
|
||||
const next =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? 1
|
||||
: ['ArrowLeft', 'ArrowRight'].includes(event.key)
|
||||
? 1 - index
|
||||
: null;
|
||||
if (next === null) return;
|
||||
event.preventDefault();
|
||||
select(next === 0 ? 'preview' : 'settings', { focus: true });
|
||||
});
|
||||
});
|
||||
listen(ui.voiceSettingsClose, 'click', () => close());
|
||||
listen(panel, 'keydown', (event) => {
|
||||
// Keep the raised row reachable across pinned trays without intercepting
|
||||
// their controls. The same lifetime owns the listeners and pending timer.
|
||||
const endGrace = () => {
|
||||
clearTimeout(graceTimer);
|
||||
if (ui.state.root.rowGrace) ui.update('root', { rowGrace: false });
|
||||
};
|
||||
offs.push(
|
||||
ui.on('settings-button', toggleSettings),
|
||||
ui.on('tab', (index) =>
|
||||
select(index === 0 ? 'preview' : 'settings', { focus: true }),
|
||||
),
|
||||
ui.on('tab-keydown', (index, event) => {
|
||||
const next =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? 1
|
||||
: ['ArrowLeft', 'ArrowRight'].includes(event.key)
|
||||
? 1 - index
|
||||
: null;
|
||||
if (next === null) return;
|
||||
event.preventDefault();
|
||||
select(next === 0 ? 'preview' : 'settings', { focus: true });
|
||||
}),
|
||||
ui.on('settings-close', () => close()),
|
||||
ui.on('panel-keydown', (event) => {
|
||||
if (event.key !== 'Escape' || !open) return;
|
||||
event.preventDefault();
|
||||
close();
|
||||
});
|
||||
// Keep the raised row reachable across pinned trays without intercepting
|
||||
// their controls. The same lifetime owns the listeners and pending timer.
|
||||
let graceTimer;
|
||||
const endGrace = () => {
|
||||
clearTimeout(graceTimer);
|
||||
if (ui.root) delete ui.root.dataset.rowGrace;
|
||||
};
|
||||
listen(ui.root, 'pointerleave', (event) => {
|
||||
}),
|
||||
ui.on('pointerleave', (event) => {
|
||||
if (event.pointerType === 'touch') return;
|
||||
endGrace();
|
||||
ui.root.dataset.rowGrace = 'true';
|
||||
ui.update('root', { rowGrace: true });
|
||||
graceTimer = setTimeout(endGrace, ROW_GRACE_MS);
|
||||
});
|
||||
listen(ui.root, 'pointerenter', endGrace);
|
||||
lifetime.signal.addEventListener('abort', endGrace, { once: true });
|
||||
sync();
|
||||
} catch (error) {
|
||||
lifetime.abort();
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
ui.on('pointerenter', endGrace),
|
||||
);
|
||||
sync();
|
||||
return {
|
||||
select,
|
||||
close,
|
||||
@@ -176,7 +150,8 @@ export function createManualVoicePanel({
|
||||
open = false;
|
||||
sync();
|
||||
disposed = true;
|
||||
lifetime.abort();
|
||||
for (const off of offs) off();
|
||||
endGrace();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,54 +1,25 @@
|
||||
import '../../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createManualVoicePanel } from './manualPanel.js';
|
||||
import { createTestVoiceUi } from '../../testSupport/voiceUi.mjs';
|
||||
import { mountVoiceView } from '../../testSupport/voiceView.mjs';
|
||||
|
||||
function fixture(t) {
|
||||
const doc = { activeElement: null };
|
||||
const node = () =>
|
||||
Object.assign(new EventTarget(), {
|
||||
hidden: false,
|
||||
isConnected: true,
|
||||
dataset: {},
|
||||
attributes: {},
|
||||
ownerDocument: doc,
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = value;
|
||||
},
|
||||
focus() {
|
||||
doc.activeElement = this;
|
||||
},
|
||||
contains(other) {
|
||||
return other === this;
|
||||
},
|
||||
checkVisibility() {
|
||||
return !this.hidden;
|
||||
},
|
||||
});
|
||||
const ui = Object.fromEntries(
|
||||
[
|
||||
'root',
|
||||
'voicePanel',
|
||||
'previewPanel',
|
||||
'voiceSettingsPanel',
|
||||
'previewTab',
|
||||
'settingsTab',
|
||||
'voiceSettingsButton',
|
||||
'voiceSettingsClose',
|
||||
'previewEmpty',
|
||||
'card',
|
||||
'button',
|
||||
].map((key) => [key, node()]),
|
||||
);
|
||||
ui.voicePanel.contains = (element) =>
|
||||
Object.values(ui).includes(element) &&
|
||||
![ui.root, ui.voiceSettingsButton, ui.button].includes(element);
|
||||
const { pinia, settle } = mountVoiceView(t);
|
||||
const ui = createTestVoiceUi({ panel: true, pinia });
|
||||
t.after(() => ui.remove());
|
||||
let enabled = true,
|
||||
content = false,
|
||||
intents = 0;
|
||||
// The card shows when presented, with Preview on and something to show.
|
||||
const card = {
|
||||
setPresented(value) {
|
||||
this.presented = value;
|
||||
ui.card.hidden = !(value && enabled && content);
|
||||
ui.replace('card', {
|
||||
...ui.state.card,
|
||||
visible: Boolean(value && enabled && content),
|
||||
});
|
||||
},
|
||||
};
|
||||
const copy = {
|
||||
@@ -63,12 +34,15 @@ function fixture(t) {
|
||||
onPresentationIntent: () => intents++,
|
||||
});
|
||||
t.after(() => panel.destroy());
|
||||
const $ = (id) => document.getElementById(id);
|
||||
return {
|
||||
ui,
|
||||
panel,
|
||||
doc,
|
||||
card,
|
||||
copy,
|
||||
settle,
|
||||
$,
|
||||
active: () => document.activeElement?.id ?? null,
|
||||
intents: () => intents,
|
||||
enabled(value) {
|
||||
enabled = value;
|
||||
@@ -80,119 +54,156 @@ function fixture(t) {
|
||||
},
|
||||
};
|
||||
}
|
||||
const click = (element) => element.dispatchEvent(new Event('click'));
|
||||
const click = (element) => element.click();
|
||||
function key(element, value) {
|
||||
const event = new Event('keydown', { cancelable: true });
|
||||
Object.defineProperty(event, 'key', { value });
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: value,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
element.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
test('Settings launcher toggles while tab and programmatic selection remain idempotent', (t) => {
|
||||
test('Settings launcher toggles while tab and programmatic selection remain idempotent', async (t) => {
|
||||
const f = fixture(t);
|
||||
await f.settle();
|
||||
assert.equal(f.panel.snapshot().panelOpen, false);
|
||||
click(f.ui.voiceSettingsButton);
|
||||
click(f.$('gev-voice-settings-button'));
|
||||
await f.settle();
|
||||
assert.equal(f.panel.snapshot().settingsOpen, true);
|
||||
assert.equal(f.doc.activeElement, f.ui.settingsTab);
|
||||
assert.equal(f.ui.voiceSettingsButton.attributes['aria-expanded'], 'true');
|
||||
click(f.ui.voiceSettingsButton);
|
||||
assert.equal(f.active(), 'gev-voice-settings-tab');
|
||||
assert.equal(
|
||||
f.$('gev-voice-settings-button').getAttribute('aria-expanded'),
|
||||
'true',
|
||||
);
|
||||
click(f.$('gev-voice-settings-button'));
|
||||
await f.settle();
|
||||
assert.equal(f.panel.snapshot().panelOpen, false);
|
||||
click(f.ui.settingsTab);
|
||||
click(f.$('gev-voice-settings-tab'));
|
||||
assert.equal(f.panel.snapshot().settingsOpen, true);
|
||||
assert.equal(f.panel.select('settings').changed, false);
|
||||
assert.equal(f.panel.snapshot().settingsOpen, true);
|
||||
click(f.ui.settingsTab);
|
||||
click(f.$('gev-voice-settings-tab'));
|
||||
assert.equal(f.panel.snapshot().settingsOpen, true);
|
||||
assert.equal(f.panel.select('unknown').reason, 'unknown-view');
|
||||
});
|
||||
|
||||
test('tab keyboard navigation and Escape preserve focus and accessibility state', (t) => {
|
||||
test('tab keyboard navigation and Escape preserve focus and accessibility state', async (t) => {
|
||||
const f = fixture(t);
|
||||
click(f.ui.voiceSettingsButton);
|
||||
assert.equal(key(f.ui.settingsTab, 'ArrowLeft').defaultPrevented, true);
|
||||
await f.settle();
|
||||
click(f.$('gev-voice-settings-button'));
|
||||
await f.settle();
|
||||
assert.equal(
|
||||
key(f.$('gev-voice-settings-tab'), 'ArrowLeft').defaultPrevented,
|
||||
true,
|
||||
);
|
||||
await f.settle();
|
||||
assert.equal(f.panel.snapshot().view, 'preview');
|
||||
assert.equal(f.doc.activeElement, f.ui.previewTab);
|
||||
assert.equal(f.ui.previewTab.tabIndex, 0);
|
||||
assert.equal(f.ui.settingsTab.tabIndex, -1);
|
||||
assert.equal(f.ui.previewTab.attributes['aria-selected'], 'true');
|
||||
key(f.ui.previewTab, 'End');
|
||||
assert.equal(f.active(), 'gev-voice-preview-tab');
|
||||
assert.equal(f.$('gev-voice-preview-tab').tabIndex, 0);
|
||||
assert.equal(f.$('gev-voice-settings-tab').tabIndex, -1);
|
||||
assert.equal(
|
||||
f.$('gev-voice-preview-tab').getAttribute('aria-selected'),
|
||||
'true',
|
||||
);
|
||||
key(f.$('gev-voice-preview-tab'), 'End');
|
||||
assert.equal(f.panel.snapshot().view, 'settings');
|
||||
key(f.ui.settingsTab, 'Home');
|
||||
key(f.$('gev-voice-settings-tab'), 'Home');
|
||||
await f.settle();
|
||||
assert.equal(f.panel.snapshot().view, 'preview');
|
||||
assert.equal(key(f.ui.voicePanel, 'Escape').defaultPrevented, true);
|
||||
assert.equal(key(f.$('gev-voice-panel'), 'Escape').defaultPrevented, true);
|
||||
await f.settle();
|
||||
assert.equal(f.panel.snapshot().panelOpen, false);
|
||||
assert.equal(f.doc.activeElement, f.ui.voiceSettingsButton);
|
||||
assert.equal(key(f.ui.voicePanel, 'Escape').defaultPrevented, false);
|
||||
assert.equal(f.active(), 'gev-voice-settings-button');
|
||||
assert.equal(key(f.$('gev-voice-panel'), 'Escape').defaultPrevented, false);
|
||||
});
|
||||
|
||||
test('manual switching transfers hidden-panel focus without stealing unrelated focus', (t) => {
|
||||
test('manual switching transfers hidden-panel focus without stealing unrelated focus', async (t) => {
|
||||
const f = fixture(t);
|
||||
f.panel.select('settings');
|
||||
f.ui.voiceSettingsPanel.focus();
|
||||
await f.settle();
|
||||
// Focus inside the Settings pane moves to the Preview tab with the switch.
|
||||
f.$('gev-voice-timeout-select').focus();
|
||||
f.panel.select('preview');
|
||||
assert.equal(f.doc.activeElement, f.ui.previewTab);
|
||||
const unrelated = (f.doc.activeElement = {});
|
||||
await f.settle();
|
||||
assert.equal(f.active(), 'gev-voice-preview-tab');
|
||||
const unrelated = document.body.appendChild(document.createElement('button'));
|
||||
unrelated.id = 'unrelated';
|
||||
unrelated.focus();
|
||||
f.panel.select('settings');
|
||||
assert.equal(f.doc.activeElement, unrelated);
|
||||
await f.settle();
|
||||
assert.equal(f.active(), 'unrelated');
|
||||
assert.equal(f.panel.close('preview').changed, false);
|
||||
assert.equal(f.panel.snapshot().settingsOpen, true);
|
||||
});
|
||||
|
||||
test('manual Preview selection never enables a disabled preference and uses injected copy', (t) => {
|
||||
test('manual Preview selection never enables a disabled preference and uses injected copy', async (t) => {
|
||||
const f = fixture(t);
|
||||
await f.settle();
|
||||
f.enabled(false);
|
||||
f.content(true);
|
||||
f.panel.select('preview');
|
||||
await f.settle();
|
||||
const empty = f.$('gev-voice-preview-empty');
|
||||
assert.equal(f.panel.snapshot().previewEnabled, false);
|
||||
assert.equal(f.panel.snapshot().previewVisible, false);
|
||||
assert.equal(f.ui.previewEmpty.textContent, f.copy.previewDisabled);
|
||||
assert.equal(f.ui.previewEmpty.hidden, false);
|
||||
assert.equal(empty.textContent, f.copy.previewDisabled);
|
||||
assert.equal(empty.hidden, false);
|
||||
f.enabled(true);
|
||||
await f.settle();
|
||||
assert.equal(f.panel.snapshot().previewVisible, true);
|
||||
assert.equal(f.ui.previewEmpty.hidden, true);
|
||||
assert.equal(empty.hidden, true);
|
||||
assert.equal(f.$('gev-voice-card').hidden, false);
|
||||
f.content(false);
|
||||
assert.equal(f.ui.previewEmpty.textContent, f.copy.previewEmpty);
|
||||
await f.settle();
|
||||
assert.equal(empty.textContent, f.copy.previewEmpty);
|
||||
});
|
||||
|
||||
test('disposal hides owned presentation and removes listeners without destroying the card', (t) => {
|
||||
test('disposal hides owned presentation and removes listeners without destroying the card', async (t) => {
|
||||
const f = fixture(t);
|
||||
f.panel.select('settings');
|
||||
await f.settle();
|
||||
const count = f.intents();
|
||||
f.panel.destroy();
|
||||
f.panel.destroy();
|
||||
click(f.ui.voiceSettingsButton);
|
||||
click(f.ui.settingsTab);
|
||||
click(f.$('gev-voice-settings-button'));
|
||||
click(f.$('gev-voice-settings-tab'));
|
||||
await f.settle();
|
||||
assert.equal(f.intents(), count);
|
||||
assert.equal(f.ui.voicePanel.hidden, true);
|
||||
assert.equal(f.$('gev-voice-panel').hidden, true);
|
||||
assert.equal(f.card.presented, false);
|
||||
assert.equal(f.panel.select('preview').reason, 'disposed');
|
||||
assert.equal(f.panel.close().reason, 'disposed');
|
||||
});
|
||||
|
||||
test('pointer grace expires, resets on reentry, ignores touch and clears on disposal', (t) => {
|
||||
test('pointer grace expires, resets on reentry, ignores touch and clears on disposal', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const f = fixture(t);
|
||||
await f.settle();
|
||||
const root = f.$('gev-voice-control');
|
||||
const pointer = (type, pointerType = 'mouse') => {
|
||||
const event = new Event(type);
|
||||
Object.defineProperty(event, 'pointerType', { value: pointerType });
|
||||
f.ui.root.dispatchEvent(event);
|
||||
root.dispatchEvent(event);
|
||||
};
|
||||
pointer('pointerleave');
|
||||
t.mock.timers.tick(699);
|
||||
assert.equal(f.ui.root.dataset.rowGrace, 'true');
|
||||
await f.settle();
|
||||
assert.equal(root.dataset.rowGrace, 'true');
|
||||
pointer('pointerenter');
|
||||
assert.equal(f.ui.root.dataset.rowGrace, undefined);
|
||||
assert.equal(f.ui.state.root.rowGrace ? 'true' : undefined, undefined);
|
||||
pointer('pointerleave');
|
||||
t.mock.timers.tick(699);
|
||||
assert.equal(f.ui.root.dataset.rowGrace, 'true');
|
||||
assert.equal(f.ui.state.root.rowGrace ? 'true' : undefined, 'true');
|
||||
t.mock.timers.tick(1);
|
||||
assert.equal(f.ui.root.dataset.rowGrace, undefined);
|
||||
assert.equal(f.ui.state.root.rowGrace ? 'true' : undefined, undefined);
|
||||
pointer('pointerleave', 'touch');
|
||||
assert.equal(f.ui.root.dataset.rowGrace, undefined);
|
||||
assert.equal(f.ui.state.root.rowGrace ? 'true' : undefined, undefined);
|
||||
pointer('pointerleave');
|
||||
f.panel.destroy();
|
||||
assert.equal(f.ui.root.dataset.rowGrace, undefined);
|
||||
assert.equal(f.ui.state.root.rowGrace ? 'true' : undefined, undefined);
|
||||
pointer('pointerleave');
|
||||
t.mock.timers.tick(700);
|
||||
assert.equal(f.ui.root.dataset.rowGrace, undefined);
|
||||
assert.equal(f.ui.state.root.rowGrace ? 'true' : undefined, undefined);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { createVoiceInactivity } from './inactivity.js';
|
||||
import { RealtimeTurns } from '../realtimeTurns.js';
|
||||
import { observeNativeActivity } from './nativeActivity.js';
|
||||
import { createTestVoiceUi } from '../../testSupport/voiceUi.mjs';
|
||||
// Deliberately small native-shaped fixtures exercise wrapper contracts, not devices.
|
||||
function gemini(onChange = () => {}) {
|
||||
const callbacks = [];
|
||||
@@ -686,15 +687,7 @@ test('actual OpenAI failed-response error/listening recovery preserves only prov
|
||||
runner: async () => ({ ok: true }),
|
||||
backend: {},
|
||||
debugSink: null,
|
||||
ui: {
|
||||
root: {
|
||||
dataset: {},
|
||||
classList: { remove() {} },
|
||||
querySelectorAll: () => [],
|
||||
},
|
||||
status: {},
|
||||
detail: {},
|
||||
},
|
||||
ui: createTestVoiceUi(),
|
||||
onSessionEvent(event) {
|
||||
if (event.type === 'state') states.push(event.state);
|
||||
observer?.handle(event);
|
||||
|
||||
@@ -102,93 +102,85 @@ export function createVoicePreferences({ storage } = {}) {
|
||||
|
||||
/** Mount-local controls, backed by an independently owned preference model. */
|
||||
export function bindPreferenceControls({ ui, preferences, copy, onChange }) {
|
||||
const {
|
||||
voicePreviewInput: preview,
|
||||
voicePreviewNote: previewNote,
|
||||
voiceTimeoutSelect: select,
|
||||
voiceTimeoutInput: input,
|
||||
voiceTimeoutNote: timeoutNote,
|
||||
} = ui;
|
||||
let disposed = false;
|
||||
const initial = preferences.snapshot();
|
||||
const displayed = parseMinutes(input?.value);
|
||||
const displayed = parseMinutes(ui.state.preferences.timeoutMinutes);
|
||||
let custom =
|
||||
!PRESETS.includes(String(initial.minutes)) && initial.minutes > 0
|
||||
? initial.minutes
|
||||
: displayed > 0
|
||||
? displayed
|
||||
: 5;
|
||||
// Every render republishes the fields, so a refused change shows the
|
||||
// applied value again.
|
||||
function render(state) {
|
||||
if (disposed) return;
|
||||
if (preview) preview.checked = state.previewEnabled;
|
||||
if (previewNote)
|
||||
previewNote.textContent = `${copy.previewNote}${state.previewPersisted === false ? ` ${copy.notSaved}` : ''}`;
|
||||
if (timeoutNote)
|
||||
timeoutNote.textContent = `${copy.timeoutNote}${state.timeoutPersisted === false ? ` ${copy.notSaved}` : ''}`;
|
||||
const value = String(state.minutes);
|
||||
const customSelected = state.minutes > 0 && !PRESETS.includes(value);
|
||||
if (customSelected) custom = state.minutes;
|
||||
if (select)
|
||||
select.value =
|
||||
state.minutes === 0 ? '0' : customSelected ? 'custom' : value;
|
||||
if (input) {
|
||||
input.value = String(custom);
|
||||
input.hidden = !customSelected;
|
||||
}
|
||||
ui.update('preferences', {
|
||||
previewChecked: state.previewEnabled,
|
||||
previewNote: `${copy.previewNote}${state.previewPersisted === false ? ` ${copy.notSaved}` : ''}`,
|
||||
timeoutNote: `${copy.timeoutNote}${state.timeoutPersisted === false ? ` ${copy.notSaved}` : ''}`,
|
||||
timeoutChoice:
|
||||
state.minutes === 0 ? '0' : customSelected ? 'custom' : value,
|
||||
timeoutMinutes: String(custom),
|
||||
timeoutInputHidden: !customSelected,
|
||||
revision: ui.state.preferences.revision + 1,
|
||||
});
|
||||
}
|
||||
const unsubscribe = preferences.subscribe((state) => {
|
||||
render(state);
|
||||
onChange?.(state);
|
||||
});
|
||||
const previewChange = () => {
|
||||
if (!disposed && !preferences.setPreviewEnabled(preview.checked).ok)
|
||||
render(preferences.snapshot());
|
||||
};
|
||||
const selectChange = () => {
|
||||
if (disposed) return;
|
||||
const value = select.value;
|
||||
if (value === 'custom') {
|
||||
const parsed = parseMinutes(input?.value);
|
||||
// A custom value may equal a preset: keep the person's explicit editor
|
||||
// choice.
|
||||
const showCustom = () =>
|
||||
ui.update('preferences', {
|
||||
timeoutChoice: 'custom',
|
||||
timeoutMinutes: String(custom),
|
||||
timeoutInputHidden: false,
|
||||
revision: ui.state.preferences.revision + 1,
|
||||
});
|
||||
const offs = [
|
||||
ui.on('preview-change', (checked) => {
|
||||
if (!disposed && !preferences.setPreviewEnabled(checked).ok)
|
||||
render(preferences.snapshot());
|
||||
}),
|
||||
ui.on('timeout-select', (value) => {
|
||||
if (disposed) return;
|
||||
if (value === 'custom') {
|
||||
const parsed = parseMinutes(ui.state.preferences.timeoutMinutes);
|
||||
if (parsed === undefined || parsed === 0) {
|
||||
render(preferences.snapshot());
|
||||
return;
|
||||
}
|
||||
custom = parsed;
|
||||
preferences.setMinutes(parsed);
|
||||
showCustom();
|
||||
} else if (value === '0' || PRESETS.includes(value))
|
||||
preferences.setMinutes(value);
|
||||
else render(preferences.snapshot());
|
||||
}),
|
||||
ui.on('timeout-input', (value) => {
|
||||
if (disposed) return;
|
||||
const parsed = parseMinutes(value);
|
||||
if (parsed === undefined || parsed === 0) {
|
||||
render(preferences.snapshot());
|
||||
return;
|
||||
}
|
||||
custom = parsed;
|
||||
preferences.setMinutes(parsed);
|
||||
// A custom value may equal a preset: retain the user's explicit editor choice.
|
||||
select.value = 'custom';
|
||||
if (input) input.hidden = false;
|
||||
} else if (value === '0' || PRESETS.includes(value))
|
||||
preferences.setMinutes(value);
|
||||
else render(preferences.snapshot());
|
||||
};
|
||||
const inputChange = () => {
|
||||
if (disposed) return;
|
||||
const parsed = parseMinutes(input.value);
|
||||
if (parsed === undefined || parsed === 0) {
|
||||
render(preferences.snapshot());
|
||||
return;
|
||||
}
|
||||
custom = parsed;
|
||||
preferences.setMinutes(parsed);
|
||||
if (select) select.value = 'custom';
|
||||
input.hidden = false;
|
||||
};
|
||||
const bindings = [
|
||||
[preview, previewChange],
|
||||
[select, selectChange],
|
||||
[input, inputChange],
|
||||
showCustom();
|
||||
}),
|
||||
];
|
||||
for (const [element, listener] of bindings)
|
||||
element?.addEventListener('change', listener);
|
||||
render(initial);
|
||||
return {
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
unsubscribe();
|
||||
for (const [element, listener] of bindings)
|
||||
element?.removeEventListener('change', listener);
|
||||
for (const off of offs) off();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
VOICE_PREVIEW_STORAGE_KEY as P,
|
||||
VOICE_TIMEOUT_STORAGE_KEY as T,
|
||||
} from './preferences.js';
|
||||
import { createTestVoiceUi } from '../../testSupport/voiceUi.mjs';
|
||||
|
||||
function storage(entries = {}, mode = 'normal') {
|
||||
const values = new Map(Object.entries(entries));
|
||||
@@ -25,39 +26,14 @@ function storage(entries = {}, mode = 'normal') {
|
||||
},
|
||||
};
|
||||
}
|
||||
class Element {
|
||||
value = '';
|
||||
checked = false;
|
||||
textContent = '';
|
||||
hidden = false;
|
||||
listeners = new Set();
|
||||
addEventListener(type, listener) {
|
||||
assert.equal(type, 'change');
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
removeEventListener(type, listener) {
|
||||
assert.equal(type, 'change');
|
||||
this.listeners.delete(listener);
|
||||
}
|
||||
change(value) {
|
||||
if (typeof value === 'boolean') this.checked = value;
|
||||
else this.value = value;
|
||||
for (const listener of [...this.listeners]) listener();
|
||||
}
|
||||
}
|
||||
// A panel control whose timeout field shows `custom` minutes, as a view
|
||||
// would display before the binder renders.
|
||||
function controls(custom = '7') {
|
||||
const ui = Object.fromEntries(
|
||||
[
|
||||
'voicePreviewInput',
|
||||
'voicePreviewNote',
|
||||
'voiceTimeoutSelect',
|
||||
'voiceTimeoutInput',
|
||||
'voiceTimeoutNote',
|
||||
].map((key) => [key, new Element()]),
|
||||
);
|
||||
ui.voiceTimeoutInput.value = custom;
|
||||
const ui = createTestVoiceUi({ panel: true });
|
||||
ui.update('preferences', { timeoutMinutes: custom });
|
||||
return ui;
|
||||
}
|
||||
const fields = (ui) => ui.state.preferences;
|
||||
const copy = {
|
||||
previewNote: 'Preview local',
|
||||
timeoutNote: 'Provider limits still apply',
|
||||
@@ -150,9 +126,9 @@ for (const mode of ['throw', 'noop', 'mismatch', 'readthrow', 'missing']) {
|
||||
const prefs = createVoicePreferences({ storage: disk });
|
||||
const a = controls();
|
||||
const bindingA = bind(a, prefs);
|
||||
a.voicePreviewInput.change(false);
|
||||
a.voiceTimeoutSelect.change('custom');
|
||||
a.voiceTimeoutInput.change('19');
|
||||
a.emit('preview-change', false);
|
||||
a.emit('timeout-select', 'custom');
|
||||
a.emit('timeout-input', '19');
|
||||
assert.deepEqual(prefs.snapshot(), {
|
||||
previewEnabled: false,
|
||||
minutes: 19,
|
||||
@@ -163,13 +139,13 @@ for (const mode of ['throw', 'noop', 'mismatch', 'readthrow', 'missing']) {
|
||||
const writeCount = disk?.writes.length;
|
||||
const b = controls();
|
||||
const bindingB = bind(b, prefs);
|
||||
assert.equal(b.voicePreviewInput.checked, false);
|
||||
assert.equal(b.voiceTimeoutInput.value, '19');
|
||||
assert.match(b.voicePreviewNote.textContent, /Not saved/);
|
||||
assert.match(b.voiceTimeoutNote.textContent, /Not saved/);
|
||||
assert.equal(fields(b).previewChecked, false);
|
||||
assert.equal(fields(b).timeoutMinutes, '19');
|
||||
assert.match(fields(b).previewNote, /Not saved/);
|
||||
assert.match(fields(b).timeoutNote, /Not saved/);
|
||||
assert.equal(disk?.writes.length, writeCount);
|
||||
a.voicePreviewInput.change(true);
|
||||
a.voiceTimeoutSelect.change('3');
|
||||
a.emit('preview-change', true);
|
||||
a.emit('timeout-select', '3');
|
||||
assert.equal(prefs.snapshot().minutes, 19);
|
||||
assert.equal(prefs.snapshot().previewEnabled, false);
|
||||
bindingB.dispose();
|
||||
@@ -187,7 +163,7 @@ test('same-value retry really writes and clears warnings after storage recovers'
|
||||
bind(ui, prefs);
|
||||
prefs.setPreviewEnabled(false);
|
||||
prefs.setMinutes(12);
|
||||
assert.match(ui.voiceTimeoutNote.textContent, /Not saved/);
|
||||
assert.match(fields(ui).timeoutNote, /Not saved/);
|
||||
disk.setItem = original;
|
||||
const timeout = prefs.setMinutes(12);
|
||||
const preview = prefs.setPreviewEnabled(false);
|
||||
@@ -199,8 +175,8 @@ test('same-value retry really writes and clears warnings after storage recovers'
|
||||
[T, '12'],
|
||||
[P, 'false'],
|
||||
]);
|
||||
assert.equal(ui.voiceTimeoutNote.textContent, copy.timeoutNote);
|
||||
assert.equal(ui.voicePreviewNote.textContent, copy.previewNote);
|
||||
assert.equal(fields(ui).timeoutNote, copy.timeoutNote);
|
||||
assert.equal(fields(ui).previewNote, copy.previewNote);
|
||||
});
|
||||
|
||||
test('Custom commits displayed valid value immediately, even when equal to preset', () => {
|
||||
@@ -209,18 +185,18 @@ test('Custom commits displayed valid value immediately, even when equal to prese
|
||||
const ui = controls('10');
|
||||
bind(ui, prefs);
|
||||
assert.equal(disk.writes.length, 0);
|
||||
ui.voiceTimeoutSelect.change('custom');
|
||||
ui.emit('timeout-select', 'custom');
|
||||
assert.equal(prefs.snapshot().minutes, 10);
|
||||
assert.equal(ui.voiceTimeoutSelect.value, 'custom');
|
||||
assert.equal(ui.voiceTimeoutInput.hidden, false);
|
||||
assert.equal(fields(ui).timeoutChoice, 'custom');
|
||||
assert.equal(fields(ui).timeoutInputHidden, false);
|
||||
assert.deepEqual(disk.writes, [[T, '10']]);
|
||||
ui.voiceTimeoutInput.change('23');
|
||||
ui.voiceTimeoutSelect.change('5');
|
||||
assert.equal(ui.voiceTimeoutInput.value, '23');
|
||||
assert.equal(ui.voiceTimeoutInput.hidden, true);
|
||||
ui.voiceTimeoutSelect.change('custom');
|
||||
ui.emit('timeout-input', '23');
|
||||
ui.emit('timeout-select', '5');
|
||||
assert.equal(fields(ui).timeoutMinutes, '23');
|
||||
assert.equal(fields(ui).timeoutInputHidden, true);
|
||||
ui.emit('timeout-select', 'custom');
|
||||
assert.equal(prefs.snapshot().minutes, 23);
|
||||
assert.equal(ui.voiceTimeoutInput.value, '23');
|
||||
assert.equal(fields(ui).timeoutMinutes, '23');
|
||||
});
|
||||
|
||||
test('invalid control edits restore valid state without writes; None remains distinct', () => {
|
||||
@@ -228,21 +204,26 @@ test('invalid control edits restore valid state without writes; None remains dis
|
||||
const prefs = createVoicePreferences({ storage: disk });
|
||||
const ui = controls();
|
||||
bind(ui, prefs);
|
||||
assert.equal(ui.voiceTimeoutInput.value, '17');
|
||||
assert.equal(fields(ui).timeoutMinutes, '17');
|
||||
for (const value of ['1.5', '', '61', '0']) {
|
||||
ui.voiceTimeoutInput.change(value);
|
||||
assert.equal(ui.voiceTimeoutInput.value, '17');
|
||||
// Each refused edit republishes the applied value, so the view writes it
|
||||
// back over what was typed.
|
||||
const revision = fields(ui).revision;
|
||||
ui.emit('timeout-input', value);
|
||||
assert.equal(fields(ui).timeoutMinutes, '17');
|
||||
assert.ok(fields(ui).revision > revision);
|
||||
assert.equal(prefs.snapshot().minutes, 17);
|
||||
}
|
||||
ui.voiceTimeoutSelect.change('malformed');
|
||||
assert.equal(ui.voiceTimeoutSelect.value, 'custom');
|
||||
ui.emit('timeout-select', 'malformed');
|
||||
assert.equal(fields(ui).timeoutChoice, 'custom');
|
||||
assert.equal(disk.writes.length, 0);
|
||||
ui.voiceTimeoutSelect.change('0');
|
||||
ui.emit('timeout-select', '0');
|
||||
assert.equal(prefs.snapshot().minutes, 0);
|
||||
assert.equal(ui.voiceTimeoutInput.hidden, true);
|
||||
ui.voiceTimeoutInput.value = 'bad';
|
||||
ui.voiceTimeoutSelect.change('custom');
|
||||
assert.equal(ui.voiceTimeoutSelect.value, '0');
|
||||
assert.equal(fields(ui).timeoutInputHidden, true);
|
||||
// The field shows an invalid value when Custom is chosen.
|
||||
ui.update('preferences', { timeoutMinutes: 'bad' });
|
||||
ui.emit('timeout-select', 'custom');
|
||||
assert.equal(fields(ui).timeoutChoice, '0');
|
||||
assert.equal(prefs.snapshot().minutes, 0);
|
||||
assert.deepEqual(disk.writes, [[T, 'none']]);
|
||||
});
|
||||
@@ -250,8 +231,25 @@ test('invalid control edits restore valid state without writes; None remains dis
|
||||
test('model snapshots cannot mutate state; unsubscribe and binder disposal release ownership', () => {
|
||||
const prefs = createVoicePreferences({ storage: storage() });
|
||||
const ui = controls();
|
||||
// Count the binder's field listeners still attached.
|
||||
const attached = new Set();
|
||||
const on = ui.on;
|
||||
ui.on = (name, listener) => {
|
||||
const off = on(name, listener);
|
||||
const entry = { name };
|
||||
attached.add(entry);
|
||||
return () => {
|
||||
attached.delete(entry);
|
||||
off();
|
||||
};
|
||||
};
|
||||
let calls = 0;
|
||||
const binding = bind(ui, prefs, () => calls++);
|
||||
assert.deepEqual([...attached].map((entry) => entry.name).sort(), [
|
||||
'preview-change',
|
||||
'timeout-input',
|
||||
'timeout-select',
|
||||
]);
|
||||
const snap = prefs.snapshot();
|
||||
snap.minutes = 41;
|
||||
assert.equal(prefs.snapshot().minutes, 5);
|
||||
@@ -264,9 +262,15 @@ test('model snapshots cannot mutate state; unsubscribe and binder disposal relea
|
||||
unsub();
|
||||
binding.dispose();
|
||||
binding.dispose();
|
||||
assert.equal(attached.size, 0);
|
||||
prefs.setMinutes(12);
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(ui.voiceTimeoutInput.value, '11');
|
||||
for (const element of Object.values(ui))
|
||||
assert.equal(element.listeners.size, 0);
|
||||
assert.equal(fields(ui).timeoutMinutes, '11');
|
||||
// A disposed binder no longer answers its fields.
|
||||
ui.emit('timeout-select', '3');
|
||||
ui.emit('timeout-input', '9');
|
||||
ui.emit('preview-change', false);
|
||||
assert.equal(prefs.snapshot().minutes, 12);
|
||||
assert.equal(prefs.snapshot().previewEnabled, true);
|
||||
assert.equal(fields(ui).timeoutMinutes, '11');
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// src/voice/panel/preferencesView.test.mjs — the rendered minutes field keeps
|
||||
// a draft across renders and shows a refused edit's applied value again.
|
||||
import '../../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
bindPreferenceControls,
|
||||
createVoicePreferences,
|
||||
VOICE_TIMEOUT_STORAGE_KEY as T,
|
||||
} from './preferences.js';
|
||||
import { createTestVoiceUi } from '../../testSupport/voiceUi.mjs';
|
||||
import { mountVoiceView } from '../../testSupport/voiceView.mjs';
|
||||
|
||||
test('a minutes draft survives unrelated renders; a refused edit is written back', async (t) => {
|
||||
const { pinia, settle } = mountVoiceView(t);
|
||||
const ui = createTestVoiceUi({ panel: true, pinia });
|
||||
ui.update('preferences', { timeoutMinutes: '17' });
|
||||
const prefs = createVoicePreferences({
|
||||
storage: { getItem: (key) => (key === T ? '17' : null), setItem() {} },
|
||||
});
|
||||
bindPreferenceControls({
|
||||
ui,
|
||||
preferences: prefs,
|
||||
copy: { previewNote: '', timeoutNote: 'Local', notSaved: 'Not saved' },
|
||||
});
|
||||
await settle();
|
||||
const input = document.getElementById('gev-voice-timeout-input');
|
||||
assert.equal(input.value, '17');
|
||||
// Typing, before the field commits: the waveform renders every frame.
|
||||
input.value = '2';
|
||||
ui.update('status', { text: 'LISTENING' });
|
||||
await settle();
|
||||
assert.equal(input.value, '2');
|
||||
input.value = '0';
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await settle();
|
||||
assert.equal(input.value, '17');
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
// Minimal DOM fixture for actual control/card/binder composition, not layout proof.
|
||||
class Element {
|
||||
constructor(doc, tag = 'div') {
|
||||
Object.assign(this, {
|
||||
ownerDocument: doc,
|
||||
tagName: tag.toUpperCase(),
|
||||
children: [],
|
||||
attributes: new Map(),
|
||||
dataset: {},
|
||||
hidden: false,
|
||||
value: '',
|
||||
checked: false,
|
||||
listeners: new Map(),
|
||||
style: { setProperty() {}, removeProperty() {} },
|
||||
});
|
||||
this.classList = {
|
||||
add: (...names) => names.forEach((n) => this.classes.add(n)),
|
||||
remove: (...names) => names.forEach((n) => this.classes.delete(n)),
|
||||
contains: (name) => this.classes.has(name),
|
||||
};
|
||||
this.classes = new Set();
|
||||
}
|
||||
set id(value) {
|
||||
this.attributes.set('id', value);
|
||||
}
|
||||
get id() {
|
||||
return this.attributes.get('id') || '';
|
||||
}
|
||||
set className(value) {
|
||||
this.classes = new Set(value.split(/\s+/).filter(Boolean));
|
||||
}
|
||||
get className() {
|
||||
return [...this.classes].join(' ');
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
if (name === 'class') this.className = value;
|
||||
if (name === 'hidden') this.hidden = true;
|
||||
if (name === 'value') this.value = value;
|
||||
if (name.startsWith('data-'))
|
||||
this.dataset[
|
||||
name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())
|
||||
] = value;
|
||||
}
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
get isConnected() {
|
||||
return (
|
||||
this === this.ownerDocument.body || Boolean(this.parentNode?.isConnected)
|
||||
);
|
||||
}
|
||||
get parentElement() {
|
||||
return this.parentNode;
|
||||
}
|
||||
set innerHTML(html) {
|
||||
this.replaceChildren();
|
||||
const stack = [this];
|
||||
const voids = new Set(['INPUT', 'IMG', 'BR', 'HR']);
|
||||
for (const match of html.matchAll(/<(\/?)([a-z][\w-]*)\b([^>]*)>/gi)) {
|
||||
if (match[1]) {
|
||||
if (stack.length > 1) stack.pop();
|
||||
continue;
|
||||
}
|
||||
const node = new Element(this.ownerDocument, match[2]);
|
||||
for (const attr of match[3].matchAll(/([^\s=\/]+)(?:="([^"]*)")?/g))
|
||||
node.setAttribute(attr[1], attr[2] ?? '');
|
||||
stack.at(-1).appendChild(node);
|
||||
if (!voids.has(node.tagName)) stack.push(node);
|
||||
}
|
||||
}
|
||||
set textContent(value) {
|
||||
this.text = String(value);
|
||||
}
|
||||
get textContent() {
|
||||
return this.text ?? '';
|
||||
}
|
||||
appendChild(node) {
|
||||
node.remove();
|
||||
this.children.push(node);
|
||||
node.parentNode = this;
|
||||
return node;
|
||||
}
|
||||
append(...nodes) {
|
||||
nodes.forEach((node) => this.appendChild(node));
|
||||
}
|
||||
insertBefore(node, before) {
|
||||
node.remove();
|
||||
const at = this.children.indexOf(before);
|
||||
this.children.splice(at < 0 ? this.children.length : at, 0, node);
|
||||
node.parentNode = this;
|
||||
}
|
||||
replaceChildren(...nodes) {
|
||||
for (const child of this.children) child.parentNode = null;
|
||||
this.children = [];
|
||||
this.append(...nodes);
|
||||
}
|
||||
remove() {
|
||||
if (this.parentNode)
|
||||
this.parentNode.children = this.parentNode.children.filter(
|
||||
(n) => n !== this,
|
||||
);
|
||||
this.parentNode = null;
|
||||
}
|
||||
contains(node) {
|
||||
return node === this || this.children.some((child) => child.contains(node));
|
||||
}
|
||||
matches(selector) {
|
||||
if (selector.startsWith('#')) return this.id === selector.slice(1);
|
||||
if (selector.startsWith('.')) return this.classes.has(selector.slice(1));
|
||||
return this.tagName === selector.toUpperCase();
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
return this.children.flatMap((n) => [
|
||||
...(n.matches(selector) ? [n] : []),
|
||||
...n.querySelectorAll(selector),
|
||||
]);
|
||||
}
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
checkVisibility() {
|
||||
return (
|
||||
this.isConnected &&
|
||||
!this.hidden &&
|
||||
(!this.parentNode || this.parentNode.checkVisibility())
|
||||
);
|
||||
}
|
||||
focus() {
|
||||
this.ownerDocument.activeElement = this;
|
||||
}
|
||||
addEventListener(type, listener, options = {}) {
|
||||
if (options.signal?.aborted) return;
|
||||
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
||||
this.listeners.get(type).add(listener);
|
||||
if (options.signal)
|
||||
options.signal.addEventListener(
|
||||
'abort',
|
||||
() => this.removeEventListener(type, listener),
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
removeEventListener(type, listener) {
|
||||
this.listeners.get(type)?.delete(listener);
|
||||
}
|
||||
fire(type, fields = {}) {
|
||||
const event = {
|
||||
type,
|
||||
target: this,
|
||||
preventDefault() {
|
||||
this.defaultPrevented = true;
|
||||
},
|
||||
...fields,
|
||||
};
|
||||
for (const listener of [...(this.listeners.get(type) || [])])
|
||||
listener.call(this, event);
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
const installed = new WeakMap();
|
||||
export function installPanelDom(t) {
|
||||
if (installed.has(t)) return installed.get(t);
|
||||
const previous = {
|
||||
window: globalThis.window,
|
||||
document: globalThis.document,
|
||||
Element: globalThis.Element,
|
||||
};
|
||||
const doc = new EventTarget();
|
||||
doc.body = new Element(doc, 'body');
|
||||
doc.documentElement = new Element(doc, 'html');
|
||||
doc.documentElement.lang = 'en';
|
||||
doc.activeElement = doc.body;
|
||||
doc.createElement = (tag) => new Element(doc, tag);
|
||||
doc.getElementById = (id) => doc.body.querySelector(`#${id}`);
|
||||
doc.querySelector = (selector) => doc.body.querySelector(selector);
|
||||
const win = new EventTarget();
|
||||
globalThis.window = win;
|
||||
globalThis.document = doc;
|
||||
globalThis.Element = Element;
|
||||
installed.set(t, doc);
|
||||
t.after(() => Object.assign(globalThis, previous));
|
||||
return doc;
|
||||
}
|
||||
|
||||
export 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { nextTick } from 'vue';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
import { mountVoiceView } from '../testSupport/voiceView.mjs';
|
||||
import { GevRealtimeController } from './realtimeController.js';
|
||||
import {
|
||||
POINTER_OPEN_MIC_RECENT_MS,
|
||||
@@ -63,16 +66,7 @@ function controllerFixture(pointer, { pushToTalk = false } = {}) {
|
||||
const sent = [];
|
||||
const events = [];
|
||||
const controller = new GevRealtimeController({
|
||||
ui: {
|
||||
root: {
|
||||
dataset: {},
|
||||
classList: { remove() {} },
|
||||
querySelectorAll: () => [],
|
||||
},
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
},
|
||||
ui: createTestVoiceUi(),
|
||||
runner: async () => ({ ok: true }),
|
||||
debugSink: null,
|
||||
pointer,
|
||||
@@ -299,20 +293,23 @@ test('card: the chip says what "this" or "here" resolved to and resets per turn'
|
||||
assert.equal(unchanged, state, 'a stale pointer on an empty chip is a no-op');
|
||||
});
|
||||
|
||||
test('markup: the chip sits under the user caption with a screen-reader separator', () => {
|
||||
const control = readFileSync(
|
||||
new URL('./control.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const user = control.indexOf('id="gev-voice-card-user"');
|
||||
const chip = control.indexOf('id="gev-voice-card-pointer"');
|
||||
const assistant = control.indexOf('id="gev-voice-card-assistant"');
|
||||
assert.ok(user < chip && chip < assistant);
|
||||
assert.match(
|
||||
control,
|
||||
/id="gev-voice-card-pointer" class="gev-voice-card-pointer" hidden/,
|
||||
);
|
||||
assert.match(control, /<span class="gev-voice-card-sr">: <\/span>/);
|
||||
test('markup: the chip sits under the user caption with a screen-reader separator', async (t) => {
|
||||
const { pinia } = mountVoiceView(t);
|
||||
const ui = createTestVoiceUi({ pinia });
|
||||
t.after(() => ui.remove());
|
||||
await nextTick();
|
||||
const card = document.getElementById('gev-voice-card');
|
||||
const order = [...card.children].map((node) => node.id);
|
||||
const user = order.indexOf('gev-voice-card-user');
|
||||
const chip = order.indexOf('gev-voice-card-pointer');
|
||||
const assistant = order.indexOf('gev-voice-card-assistant');
|
||||
assert.ok(user >= 0 && user < chip && chip < assistant);
|
||||
const pointer = document.getElementById('gev-voice-card-pointer');
|
||||
assert.equal(pointer.className, 'gev-voice-card-pointer');
|
||||
assert.equal(pointer.hidden, true);
|
||||
const separator = pointer.querySelector('.gev-voice-card-sr');
|
||||
assert.equal(separator.textContent, ': ');
|
||||
assert.equal(separator.previousElementSibling.id, 'gev-voice-card-pointer-kind');
|
||||
});
|
||||
|
||||
test('deixis prompt lint: one short rule, and every sentinel it names exists in the schema', () => {
|
||||
|
||||
@@ -80,7 +80,8 @@ export function createProviderCommands(options, { bind, factories, storage }) {
|
||||
mount();
|
||||
if (resume && !disposed && provider === next)
|
||||
void current.session.start({ pushToTalk });
|
||||
globalThis.document?.getElementById('gev-voice-provider')?.focus();
|
||||
// The new control renders first; focus stays on the provider choice.
|
||||
void current.ui?.focus?.(['provider'], { anyVisibility: true });
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createGevActionRunner } from './gevActions.js';
|
||||
import { createPointerTracker } from './pointerContext.js';
|
||||
import { createReferentRegistry } from './referents.js';
|
||||
import { describeUnsavedWork, t as translate } from '../i18n/index.js';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
|
||||
const PROVIDER_KEY = 'gev.voice.provider';
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
@@ -25,28 +26,31 @@ function deferred() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
// Listener counts observe the actual binder's registrations/removals.
|
||||
class Element extends EventTarget {
|
||||
listeners = new Map();
|
||||
attributes = new Map();
|
||||
addEventListener(type, handler, options) {
|
||||
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
||||
this.listeners.get(type).add(handler);
|
||||
super.addEventListener(type, handler, options);
|
||||
}
|
||||
removeEventListener(type, handler, options) {
|
||||
this.listeners.get(type)?.delete(handler);
|
||||
super.removeEventListener(type, handler, options);
|
||||
}
|
||||
listenerCount(type) {
|
||||
return this.listeners.get(type)?.size || 0;
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
}
|
||||
focus() {
|
||||
this.focusCount = (this.focusCount || 0) + 1;
|
||||
}
|
||||
// A real control whose listeners, removals and provider focus are counted.
|
||||
// Removal is counted but keeps the control, so an interaction after teardown
|
||||
// still reaches any listener the binder failed to release.
|
||||
function countedControl(options, onRemove = () => {}) {
|
||||
const ui = createTestVoiceUi(options);
|
||||
const counts = new Map();
|
||||
const on = ui.on;
|
||||
ui.on = (name, listener) => {
|
||||
counts.set(name, (counts.get(name) || 0) + 1);
|
||||
const off = on(name, listener);
|
||||
let live = true;
|
||||
return () => {
|
||||
if (live) counts.set(name, counts.get(name) - 1);
|
||||
live = false;
|
||||
off();
|
||||
};
|
||||
};
|
||||
ui.listenerCount = (name) => counts.get(name) || 0;
|
||||
ui.removeCount = 0;
|
||||
ui.remove = () => {
|
||||
ui.removeCount++;
|
||||
onRemove();
|
||||
};
|
||||
ui.focusCount = 0;
|
||||
return ui;
|
||||
}
|
||||
|
||||
for (const custom of [false, true]) {
|
||||
@@ -58,13 +62,7 @@ for (const custom of [false, true]) {
|
||||
controls?.session.destroy();
|
||||
globalThis.window = prior;
|
||||
});
|
||||
const ui = {
|
||||
root: { dataset: {}, remove() {} },
|
||||
button: new Element(),
|
||||
status: {},
|
||||
detail: {},
|
||||
providerField: {},
|
||||
};
|
||||
const ui = countedControl();
|
||||
let defaultCreated = 0;
|
||||
let customCreated = 0;
|
||||
const options = {
|
||||
@@ -108,8 +106,8 @@ for (const custom of [false, true]) {
|
||||
controls.session.sendText('test'),
|
||||
`${custom ? 'custom' : 'default'}:test`,
|
||||
);
|
||||
assert.equal(ui.providerField.hidden, true);
|
||||
assert.equal(ui.root.dataset.providerChoice, 'false');
|
||||
assert.equal(ui.state.provider.hidden, true);
|
||||
assert.equal(ui.state.root.providerChoice, 'false');
|
||||
if (!custom)
|
||||
assert.deepEqual(controls.session.adapter.getStartOptions(), {
|
||||
pushToTalk: true,
|
||||
@@ -153,7 +151,13 @@ function compositionFixture(
|
||||
globalThis.window = new EventTarget();
|
||||
globalThis.document = {
|
||||
getElementById: (id) =>
|
||||
id === 'gev-voice-provider' ? views.at(-1)?.providerSelect : null,
|
||||
id === 'gev-voice-provider'
|
||||
? {
|
||||
focus() {
|
||||
views.at(-1).focusCount++;
|
||||
},
|
||||
}
|
||||
: null,
|
||||
};
|
||||
t.after(async () => {
|
||||
try {
|
||||
@@ -313,35 +317,10 @@ function compositionFixture(
|
||||
]),
|
||||
);
|
||||
|
||||
function createControl() {
|
||||
function createControl(options) {
|
||||
const id = views.length;
|
||||
order.push(`control:${id}`);
|
||||
const providerSelect = new Element();
|
||||
providerSelect.ownerDocument = { createElement: () => ({}) };
|
||||
providerSelect.replaceChildren = (...options) => {
|
||||
providerSelect.options = options;
|
||||
};
|
||||
const view = {
|
||||
root: {
|
||||
dataset: {},
|
||||
classList: { remove() {} },
|
||||
removeCount: 0,
|
||||
remove() {
|
||||
this.removeCount++;
|
||||
order.push(`remove:${id}`);
|
||||
},
|
||||
},
|
||||
button: new Element(),
|
||||
providerSelect,
|
||||
providerField: {},
|
||||
status: {},
|
||||
detail: {},
|
||||
errorDetail: {},
|
||||
tierButton: {},
|
||||
costValue: {},
|
||||
helpDetail: {},
|
||||
card: {},
|
||||
};
|
||||
const view = countedControl(options, () => order.push(`remove:${id}`));
|
||||
views.push(view);
|
||||
return view;
|
||||
}
|
||||
@@ -431,9 +410,7 @@ function compositionFixture(
|
||||
storageWrites,
|
||||
state,
|
||||
select(provider) {
|
||||
const select = views.at(-1).providerSelect;
|
||||
select.value = provider;
|
||||
select.dispatchEvent(new Event('change'));
|
||||
views.at(-1).emit('provider-change', provider);
|
||||
},
|
||||
seedConversation() {
|
||||
pointer.beginTurn('text', {
|
||||
@@ -518,15 +495,15 @@ for (const scenario of [
|
||||
assert.equal(Object.isFrozen(f.facade.supportedProviders), true);
|
||||
assert.equal(f.facade.provider, scenario.provider);
|
||||
assert.deepEqual(
|
||||
f.views[0].providerSelect.options.map((option) => option.value),
|
||||
f.views[0].state.provider.options.map((option) => option.value),
|
||||
scenario.expected,
|
||||
);
|
||||
assert.equal(
|
||||
f.views[0].providerField.hidden,
|
||||
f.views[0].state.provider.hidden,
|
||||
scenario.expected.length === 1,
|
||||
);
|
||||
assert.equal(
|
||||
f.views[0].root.dataset.providerChoice,
|
||||
f.views[0].state.root.providerChoice,
|
||||
String(scenario.expected.length > 1),
|
||||
);
|
||||
assert.equal(f.instances.length, 1);
|
||||
@@ -599,12 +576,12 @@ test('active switch keeps PTT mode, retires the held key, and disposes before mo
|
||||
assert.ok(f.order.indexOf('stop:0') < f.order.indexOf('control:1'));
|
||||
assert.ok(f.order.indexOf('remove:0') < f.order.indexOf('control:1'));
|
||||
assert.ok(f.order.indexOf('stop:0') < f.order.indexOf('factory:1'));
|
||||
assert.equal(f.views[0].button.listenerCount('click'), 0);
|
||||
assert.equal(f.views[0].providerSelect.listenerCount('change'), 0);
|
||||
assert.equal(f.views[1].providerSelect.listenerCount('change'), 1);
|
||||
assert.equal(f.views[1].providerSelect.focusCount, 1);
|
||||
assert.equal(f.views[1].tierButton.hidden, true);
|
||||
assert.equal(f.views[1].costValue.hidden, true);
|
||||
assert.equal(f.views[0].listenerCount('button'), 0);
|
||||
assert.equal(f.views[0].listenerCount('provider-change'), 0);
|
||||
assert.equal(f.views[1].listenerCount('provider-change'), 1);
|
||||
assert.equal(f.views[1].focusCount, 1);
|
||||
assert.equal(f.views[1].state.tier.hidden, true);
|
||||
assert.equal(f.views[1].state.cost.hidden, true);
|
||||
assert.equal(f.facade.providerDiagnostic(), 'gemini:1');
|
||||
assert.equal(f.pointer.activeSnapshot(), null);
|
||||
assert.deepEqual(f.referents.list(), []);
|
||||
@@ -798,15 +775,14 @@ test('outer destruction disposes the runner once and removes each provider bindi
|
||||
const finalEvents = f.events.length;
|
||||
const starts = f.instances.map((instance) => instance.starts.length);
|
||||
for (const [index, view] of f.views.entries()) {
|
||||
assert.equal(view.root.removeCount, 1);
|
||||
assert.equal(view.removeCount, 1);
|
||||
assert.equal(view.cardView.destroyed, 1);
|
||||
assert.equal(view.reticle.destroyed, 1);
|
||||
assert.equal(view.button.listenerCount('click'), 0);
|
||||
assert.equal(view.providerSelect.listenerCount('change'), 0);
|
||||
assert.equal(view.listenerCount('button'), 0);
|
||||
assert.equal(view.listenerCount('provider-change'), 0);
|
||||
assert.equal(f.instances[index].hooks.signal.aborted, true);
|
||||
view.button.dispatchEvent(new Event('click'));
|
||||
view.providerSelect.value = 'openai';
|
||||
view.providerSelect.dispatchEvent(new Event('change'));
|
||||
view.emit('button');
|
||||
view.emit('provider-change', 'openai');
|
||||
f.instances[index].hooks.emit({ type: 'state', state: 'connecting' });
|
||||
f.mounts[index].callback({ type: 'state', state: 'connecting' });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
import { createRealtimeBackend } from './realtimeBackend.js';
|
||||
import { GevRealtimeController } from './realtimeController.js';
|
||||
|
||||
@@ -131,11 +132,7 @@ test('controller lifetime stops pending transport and releases resources through
|
||||
runner: async () => ({}),
|
||||
signal: lifetime.signal,
|
||||
debugSink: null,
|
||||
ui: {
|
||||
root: { dataset: {}, querySelectorAll: () => [], remove() {} },
|
||||
status: {},
|
||||
detail: {},
|
||||
},
|
||||
ui: createTestVoiceUi(),
|
||||
});
|
||||
controller.connectionAbort = new AbortController();
|
||||
const connection = controller.connectionAbort.signal;
|
||||
|
||||
@@ -157,7 +157,6 @@ export class GevRealtimeController extends RealtimeFacade {
|
||||
},
|
||||
});
|
||||
|
||||
this.buttonHandler = null;
|
||||
this.tierHandler = null;
|
||||
this.annotationEventUnsubscribe = null;
|
||||
|
||||
@@ -379,12 +378,8 @@ export class GevRealtimeController extends RealtimeFacade {
|
||||
this._radio.clearPendingPlayback();
|
||||
this._viewport.reset();
|
||||
this._input.resetSession();
|
||||
if (removeUi && this.ui?.button && this.buttonHandler) {
|
||||
this.ui.button.removeEventListener('click', this.buttonHandler);
|
||||
this.buttonHandler = null;
|
||||
}
|
||||
if (removeUi && this.ui?.tierButton && this.tierHandler) {
|
||||
this.ui.tierButton.removeEventListener('click', this.tierHandler);
|
||||
if (removeUi && this.tierHandler) {
|
||||
this.tierHandler();
|
||||
this.tierHandler = null;
|
||||
}
|
||||
if (removeUi) this._input.detachBindings();
|
||||
@@ -395,9 +390,7 @@ export class GevRealtimeController extends RealtimeFacade {
|
||||
this.annotationEventUnsubscribe = null;
|
||||
}
|
||||
if (removeUi) this._radio.detachObservers();
|
||||
if (removeUi && this.ui?.root) {
|
||||
this.ui.root.remove();
|
||||
}
|
||||
if (removeUi) this.ui?.remove();
|
||||
if (!preserveStatus && !removeUi) {
|
||||
this.setStatus('idle', t('Voice off'));
|
||||
}
|
||||
@@ -416,11 +409,13 @@ export class GevRealtimeController extends RealtimeFacade {
|
||||
setStatus(status, detail) {
|
||||
this.status = status;
|
||||
this.emitSessionEvent({ type: 'state', state: status, detail });
|
||||
this.ui.root.dataset.status = status;
|
||||
if (status === 'error') this.ui.root.classList.remove('error-dismissed');
|
||||
this.ui.update('root', {
|
||||
status,
|
||||
...(status === 'error' && { errorDismissed: false }),
|
||||
});
|
||||
this.updateVoiceButtonLabel();
|
||||
const shownStatus = STATUS_STATES.has(status) ? status : 'idle';
|
||||
this.ui.status.textContent = voiceStatusLabel(shownStatus);
|
||||
this.ui.update('status', { text: voiceStatusLabel(shownStatus) });
|
||||
const resolvedDetail =
|
||||
status === 'listening' && this.pushToTalkMode
|
||||
? this.pushToTalkKeyHeld
|
||||
@@ -432,14 +427,13 @@ export class GevRealtimeController extends RealtimeFacade {
|
||||
? t('VOICE UNAVAILABLE')
|
||||
: resolvedDetail ||
|
||||
(status === 'idle' ? t('VOICE STANDBY') : t('VOICE ACTIVE'));
|
||||
this.ui.detail.textContent = primaryDetail;
|
||||
this.ui.detail.title = primaryDetail;
|
||||
if (this.ui.errorDetail) {
|
||||
this.ui.errorDetail.textContent =
|
||||
this.ui.update('detail', { text: primaryDetail, title: primaryDetail });
|
||||
this.ui.update('error', {
|
||||
text:
|
||||
status === 'error'
|
||||
? resolvedDetail || t('Voice session could not be started.')
|
||||
: '';
|
||||
}
|
||||
: '',
|
||||
});
|
||||
if (status === 'idle' || status === 'connecting' || status === 'error') {
|
||||
this.setVoiceSpeaker('idle');
|
||||
}
|
||||
|
||||
+36
-40
@@ -46,27 +46,25 @@ export class RealtimeCost {
|
||||
*/
|
||||
syncCostUi() {
|
||||
const state = this.costTracker.state();
|
||||
const syncWarning = () => {
|
||||
const warning = this.ui?.costWarning;
|
||||
const value = this.ui?.costValue;
|
||||
if (!warning || !value) return;
|
||||
warning.hidden = !['warn', 'cap', 'unknown'].includes(
|
||||
value.dataset.level,
|
||||
);
|
||||
warning.textContent = warning.hidden ? '' : value.textContent;
|
||||
warning.title = value.title;
|
||||
warning.dataset.level = value.dataset.level;
|
||||
// The panel's warning outside the closed panel mirrors the readout.
|
||||
const showCost = (cost) => {
|
||||
this.ui.update('cost', { hidden: false, ...cost });
|
||||
const hidden = !['warn', 'cap', 'unknown'].includes(cost.level);
|
||||
this.ui.update('costWarning', {
|
||||
hidden,
|
||||
text: hidden ? '' : cost.text,
|
||||
title: cost.title,
|
||||
level: cost.level,
|
||||
});
|
||||
};
|
||||
const pendingTier = resolveVoiceModel(this.voiceTier).tier;
|
||||
const isMini = pendingTier === 'mini';
|
||||
if (this.ui?.tierButton) {
|
||||
this.ui.tierButton.textContent = isMini ? t('MINI') : t('STD');
|
||||
this.ui.tierButton.setAttribute(
|
||||
'aria-pressed',
|
||||
isMini ? 'true' : 'false',
|
||||
);
|
||||
const pendingId = resolveVoiceModel(pendingTier).id;
|
||||
this.ui.tierButton.title =
|
||||
if (!this.ui) return;
|
||||
const pendingId = resolveVoiceModel(pendingTier).id;
|
||||
this.ui.update('tier', {
|
||||
text: isMini ? t('MINI') : t('STD'),
|
||||
pressed: isMini,
|
||||
title:
|
||||
this.isActive() && state.modelId !== pendingId
|
||||
? t('Next session: {pendingId} — this session stays on {modelId}', {
|
||||
pendingId,
|
||||
@@ -78,16 +76,15 @@ export class RealtimeCost {
|
||||
pendingId,
|
||||
target: isMini ? t('standard') : t('mini'),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (this.ui?.costValue) {
|
||||
const liveAuth = this.sessionCloudVoiceAuth || this.cloudVoiceAuth;
|
||||
if (liveAuth === 'oauth') {
|
||||
const { responses, input, output, captions } = this.oauthUsage;
|
||||
this.ui.costValue.hidden = false;
|
||||
this.ui.costValue.textContent = t('COST UNKNOWN');
|
||||
this.ui.costValue.dataset.level = 'unknown';
|
||||
this.ui.costValue.title =
|
||||
),
|
||||
});
|
||||
const liveAuth = this.sessionCloudVoiceAuth || this.cloudVoiceAuth;
|
||||
if (liveAuth === 'oauth') {
|
||||
const { responses, input, output, captions } = this.oauthUsage;
|
||||
showCost({
|
||||
text: t('COST UNKNOWN'),
|
||||
level: 'unknown',
|
||||
title:
|
||||
t(
|
||||
'ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.',
|
||||
{ responses, input, output, captions },
|
||||
@@ -101,14 +98,14 @@ export class RealtimeCost {
|
||||
t(
|
||||
'Usage is incomplete because a response was still in flight when the session ended.',
|
||||
)
|
||||
: '');
|
||||
syncWarning();
|
||||
return;
|
||||
}
|
||||
this.ui.costValue.hidden = false;
|
||||
this.ui.costValue.textContent = state.display;
|
||||
this.ui.costValue.dataset.level = state.level;
|
||||
this.ui.costValue.title =
|
||||
: ''),
|
||||
});
|
||||
return;
|
||||
}
|
||||
showCost({
|
||||
text: state.display,
|
||||
level: state.level,
|
||||
title:
|
||||
t(
|
||||
'Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.',
|
||||
{
|
||||
@@ -125,9 +122,8 @@ export class RealtimeCost {
|
||||
t(
|
||||
'Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.',
|
||||
)
|
||||
: '');
|
||||
syncWarning();
|
||||
}
|
||||
: ''),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,7 +162,7 @@ export class RealtimeCost {
|
||||
});
|
||||
}
|
||||
this.syncCostUi();
|
||||
if (this.isActive() && this.ui?.detail) {
|
||||
if (this.isActive() && this.ui) {
|
||||
// The tier as the interface names it; the stored value stays English.
|
||||
const tier =
|
||||
resolveVoiceModel(this.voiceTier).tier === 'mini'
|
||||
|
||||
@@ -5,6 +5,7 @@ import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { RealtimeCost } from './realtimeCost.js';
|
||||
import { createVoiceCostTracker } from './voiceCost.js';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`API-key cost tooltip for an incomplete session has no English (${locale})`, () => {
|
||||
@@ -18,12 +19,10 @@ for (const locale of ['es', 'ru']) {
|
||||
cloudVoiceAuth: 'api-key',
|
||||
sessionCloudVoiceAuth: 'api-key',
|
||||
isActive: () => false,
|
||||
ui: {
|
||||
costValue: { hidden: true, dataset: {}, title: '', textContent: '' },
|
||||
},
|
||||
ui: createTestVoiceUi(),
|
||||
};
|
||||
RealtimeCost.prototype.syncCostUi.call(host);
|
||||
const title = host.ui.costValue.title;
|
||||
const title = host.ui.state.cost.title;
|
||||
assert.ok(title.length > 0);
|
||||
assert.doesNotMatch(
|
||||
title,
|
||||
@@ -45,10 +44,8 @@ test('API-key cost tooltip in English still ends with the tracker note', () => {
|
||||
cloudVoiceAuth: 'api-key',
|
||||
sessionCloudVoiceAuth: 'api-key',
|
||||
isActive: () => false,
|
||||
ui: {
|
||||
costValue: { hidden: true, dataset: {}, title: '', textContent: '' },
|
||||
},
|
||||
ui: createTestVoiceUi(),
|
||||
};
|
||||
RealtimeCost.prototype.syncCostUi.call(host);
|
||||
assert.ok(host.ui.costValue.title.endsWith(` ${costTracker.state().note}`));
|
||||
assert.ok(host.ui.state.cost.title.endsWith(` ${costTracker.state().note}`));
|
||||
});
|
||||
@@ -1,13 +1,32 @@
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createVoiceControl } from './control.js';
|
||||
import { createApp, h, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { RealtimeCost } from './realtimeCost.js';
|
||||
import { createVoiceCostTracker, resolveVoiceModel } from './voiceCost.js';
|
||||
import { installPanelDom } from './panel/testDom.mjs';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
import { useVoiceStore } from '../ui/stores/voice.js';
|
||||
import VoiceControl from '../ui/views/panels/VoiceControl.vue';
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
// Shown unless it or an ancestor is hidden.
|
||||
const visible = (selector) => {
|
||||
const node = $(selector);
|
||||
return Boolean(node) && !node.closest('[hidden]');
|
||||
};
|
||||
|
||||
function harness(t) {
|
||||
installPanelDom(t);
|
||||
const ui = createVoiceControl({ panel: true });
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const ui = createTestVoiceUi({ panel: true, pinia });
|
||||
const store = useVoiceStore(pinia);
|
||||
// The dock renders the presented control; this renders only that.
|
||||
const app = createApp({
|
||||
render: () => store.view && h(VoiceControl, { view: store.view }),
|
||||
}).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
const stops = [];
|
||||
const statuses = [];
|
||||
const cost = new RealtimeCost({
|
||||
@@ -39,62 +58,75 @@ function harness(t) {
|
||||
return { cost, ui, stops, statuses, recordThreeDollars };
|
||||
}
|
||||
|
||||
test('soft cost warning is visible outside closed Settings, while ordinary spend stays inside', (t) => {
|
||||
test('soft cost warning is visible outside closed Settings, while ordinary spend stays inside', async (t) => {
|
||||
const { cost, ui, recordThreeDollars } = harness(t);
|
||||
cost.syncCostUi();
|
||||
assert.equal(ui.voicePanel.hidden, true);
|
||||
assert.equal(ui.costValue.checkVisibility(), false);
|
||||
assert.equal(ui.costWarning.hidden, true);
|
||||
assert.equal(ui.voicePanel.contains(ui.costWarning), false);
|
||||
assert.equal(ui.root.querySelectorAll('#gev-voice-cost-value').length, 1);
|
||||
await nextTick();
|
||||
assert.equal(ui.state.voicePanel.hidden, true);
|
||||
assert.equal($('#gev-voice-panel').hidden, true);
|
||||
assert.equal(visible('#gev-voice-cost-value'), false);
|
||||
assert.equal($('#gev-voice-cost-warning').hidden, true);
|
||||
assert.equal(
|
||||
$('#gev-voice-panel').contains($('#gev-voice-cost-warning')),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
$('#gev-voice-control').querySelectorAll('#gev-voice-cost-value').length,
|
||||
1,
|
||||
);
|
||||
|
||||
recordThreeDollars();
|
||||
assert.equal(ui.costWarning.checkVisibility(), true);
|
||||
assert.equal(ui.costWarning.dataset.level, 'warn');
|
||||
assert.equal(ui.costWarning.textContent, ui.costValue.textContent);
|
||||
assert.equal(ui.costWarning.title, ui.costValue.title);
|
||||
assert.match(ui.costWarning.title, /Warns at/);
|
||||
await nextTick();
|
||||
const warning = $('#gev-voice-cost-warning');
|
||||
const value = $('#gev-voice-cost-value');
|
||||
assert.equal(visible('#gev-voice-cost-warning'), true);
|
||||
assert.equal(warning.dataset.level, 'warn');
|
||||
assert.equal(warning.textContent, value.textContent);
|
||||
assert.equal(warning.title, value.title);
|
||||
assert.match(warning.title, /Warns at/);
|
||||
assert.equal(
|
||||
ui.voicePanel.hidden,
|
||||
$('#gev-voice-panel').hidden,
|
||||
true,
|
||||
'warning never forces Settings open',
|
||||
);
|
||||
|
||||
cost.prepareSession();
|
||||
cost.syncCostUi();
|
||||
await nextTick();
|
||||
assert.equal(
|
||||
ui.costWarning.hidden,
|
||||
$('#gev-voice-cost-warning').hidden,
|
||||
true,
|
||||
'new session clears the old warning',
|
||||
);
|
||||
assert.equal(ui.costWarning.textContent, '');
|
||||
assert.equal($('#gev-voice-cost-warning').textContent, '');
|
||||
});
|
||||
|
||||
test('closed Settings cannot hide the hard cap or suppress its ordinary stop and status', (t) => {
|
||||
const { ui, stops, statuses, recordThreeDollars } = harness(t);
|
||||
test('closed Settings cannot hide the hard cap or suppress its ordinary stop and status', async (t) => {
|
||||
const { stops, statuses, recordThreeDollars } = harness(t);
|
||||
recordThreeDollars();
|
||||
assert.equal(stops.length, 0);
|
||||
recordThreeDollars();
|
||||
assert.equal(ui.costWarning.checkVisibility(), true);
|
||||
assert.equal(ui.costWarning.dataset.level, 'cap');
|
||||
await nextTick();
|
||||
assert.equal(visible('#gev-voice-cost-warning'), true);
|
||||
assert.equal($('#gev-voice-cost-warning').dataset.level, 'cap');
|
||||
assert.deepEqual(stops, [{ preserveStatus: true }]);
|
||||
assert.equal(statuses.at(-1)[0], 'idle');
|
||||
assert.match(statuses.at(-1)[1], /Session ended — cost cap/);
|
||||
});
|
||||
|
||||
test('unknown OAuth cost remains visible outside Settings and clears on a fresh API meter', (t) => {
|
||||
const { cost, ui } = harness(t);
|
||||
test('unknown OAuth cost remains visible outside Settings and clears on a fresh API meter', async (t) => {
|
||||
const { cost } = harness(t);
|
||||
cost.sessionCloudVoiceAuth = 'oauth';
|
||||
cost.syncCostUi();
|
||||
assert.equal(ui.costWarning.checkVisibility(), true);
|
||||
assert.equal(ui.costWarning.dataset.level, 'unknown');
|
||||
assert.equal(ui.costWarning.textContent, 'COST UNKNOWN');
|
||||
assert.match(
|
||||
ui.costWarning.title,
|
||||
/USD cost and the API spend cap are unavailable/,
|
||||
);
|
||||
assert.equal(ui.voicePanel.hidden, true);
|
||||
await nextTick();
|
||||
const warning = $('#gev-voice-cost-warning');
|
||||
assert.equal(visible('#gev-voice-cost-warning'), true);
|
||||
assert.equal(warning.dataset.level, 'unknown');
|
||||
assert.equal(warning.textContent, 'COST UNKNOWN');
|
||||
assert.match(warning.title, /USD cost and the API spend cap are unavailable/);
|
||||
assert.equal($('#gev-voice-panel').hidden, true);
|
||||
cost.sessionCloudVoiceAuth = 'api-key';
|
||||
cost.syncCostUi();
|
||||
assert.equal(ui.costWarning.hidden, true);
|
||||
await nextTick();
|
||||
assert.equal($('#gev-voice-cost-warning').hidden, true);
|
||||
});
|
||||
@@ -4,10 +4,10 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale, t } from '../i18n/index.js';
|
||||
import { RealtimeCost } from './realtimeCost.js';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
|
||||
function harness() {
|
||||
const button = { textContent: '', title: '', setAttribute() {} };
|
||||
const ui = { tierButton: button, detail: {} };
|
||||
const ui = createTestVoiceUi();
|
||||
const statusCalls = [];
|
||||
const cost = new RealtimeCost({
|
||||
readUi: () => ui,
|
||||
@@ -18,7 +18,7 @@ function harness() {
|
||||
setStatus: (status, detail) => statusCalls.push(detail),
|
||||
},
|
||||
});
|
||||
return { cost, button, statusCalls };
|
||||
return { cost, ui, statusCalls };
|
||||
}
|
||||
|
||||
const EXPECTED = {
|
||||
@@ -41,10 +41,13 @@ for (const locale of Object.keys(EXPECTED)) {
|
||||
setLocale(locale);
|
||||
ctx.after(() => setLocale('en'));
|
||||
for (const tier of ['mini', 'standard']) {
|
||||
const { cost, button, statusCalls } = harness();
|
||||
const { cost, ui, statusCalls } = harness();
|
||||
assert.equal(cost.setVoiceTier(tier), tier, 'stored tier stays English');
|
||||
assert.equal(statusCalls.at(-1), EXPECTED[locale][tier]);
|
||||
assert.equal(button.textContent, tier === 'mini' ? t('MINI') : t('STD'));
|
||||
assert.equal(
|
||||
ui.state.tier.text,
|
||||
tier === 'mini' ? t('MINI') : t('STD'),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
+31
-36
@@ -11,8 +11,8 @@ import {
|
||||
resolveVoiceVisualizerSpeaker,
|
||||
resolveVoiceControlHint,
|
||||
gateVoiceVisualizerLevel,
|
||||
resetVoiceVisualizerBars,
|
||||
} from './realtimeInputPolicy.js';
|
||||
import { VOICE_VISUALIZER_BARS } from './control.js';
|
||||
import { shouldPauseRadioForVoice } from './realtimeProtocol.js';
|
||||
|
||||
/** Own physical push-to-talk gestures, microphone controls and audio meters. */
|
||||
@@ -116,7 +116,7 @@ export class RealtimeInput {
|
||||
// off if it is talking. Short taps never reach this point.
|
||||
if (mayInterrupt) this.bargeIn?.();
|
||||
this.beginPointerTurn?.('keydown');
|
||||
this.ui.root.dataset.pushToTalk = 'held';
|
||||
this.ui.update('root', { pushToTalk: 'held' });
|
||||
this.setMicrophoneEnabled(true);
|
||||
if (this.status === 'listening')
|
||||
this.setStatus('listening', t('Release Space to send'));
|
||||
@@ -124,7 +124,8 @@ export class RealtimeInput {
|
||||
this.start({ pushToTalk: true });
|
||||
// start() performs a controlled stop() before connecting. Restore the
|
||||
// marker it clears so the UI reflects this still-held gesture.
|
||||
if (this.pushToTalkKeyHeld) this.ui.root.dataset.pushToTalk = 'held';
|
||||
if (this.pushToTalkKeyHeld)
|
||||
this.ui.update('root', { pushToTalk: 'held' });
|
||||
}
|
||||
}, PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
};
|
||||
@@ -191,7 +192,7 @@ export class RealtimeInput {
|
||||
this.releasePointerHold?.();
|
||||
if (!this.pushToTalkKeyHeld) return;
|
||||
this.pushToTalkKeyHeld = false;
|
||||
delete this.ui.root.dataset.pushToTalk;
|
||||
this.ui.update('root', { pushToTalk: null });
|
||||
if (!this.pushToTalkMode) return;
|
||||
this.setMicrophoneEnabled(false);
|
||||
if (this.status === 'listening')
|
||||
@@ -205,8 +206,7 @@ export class RealtimeInput {
|
||||
* @returns {void}
|
||||
*/
|
||||
setMicrophoneEnabled(enabled) {
|
||||
if (this.ui?.root)
|
||||
this.ui.root.dataset.microphone = enabled ? 'active' : 'muted';
|
||||
this.ui?.update('root', { microphone: enabled ? 'active' : 'muted' });
|
||||
this.stream?.getAudioTracks?.().forEach((track) => {
|
||||
track.enabled = Boolean(enabled);
|
||||
});
|
||||
@@ -220,10 +220,7 @@ export class RealtimeInput {
|
||||
startVoiceVisualizer(stream) {
|
||||
this.stopVoiceVisualizer();
|
||||
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||
const bars = Array.from(
|
||||
this.ui.root.querySelectorAll('.gev-voice-visualizer span'),
|
||||
);
|
||||
if (!AudioContextClass || !stream || !bars.length) return;
|
||||
if (!AudioContextClass || !stream || !this.ui) return;
|
||||
try {
|
||||
const context = new AudioContextClass();
|
||||
this.visualizerAudioContext = context;
|
||||
@@ -252,17 +249,18 @@ export class RealtimeInput {
|
||||
},
|
||||
);
|
||||
if (!signal) {
|
||||
resetVoiceVisualizerBars(bars);
|
||||
this.restVoiceVisualizer();
|
||||
this.visualizerFrame = requestAnimationFrame(render);
|
||||
return;
|
||||
}
|
||||
signal.analyser.getByteFrequencyData(signal.data);
|
||||
const binCount = signal.data.length;
|
||||
bars.forEach((bar, index) => {
|
||||
const start = Math.floor((index / bars.length) * binCount);
|
||||
const count = VOICE_VISUALIZER_BARS;
|
||||
const bars = Array.from({ length: count }, (_, index) => {
|
||||
const start = Math.floor((index / count) * binCount);
|
||||
const end = Math.max(
|
||||
start + 1,
|
||||
Math.floor(((index + 1) / bars.length) * binCount),
|
||||
Math.floor(((index + 1) / count) * binCount),
|
||||
);
|
||||
let energy = 0;
|
||||
for (let bin = start; bin < end; bin++) energy += signal.data[bin];
|
||||
@@ -275,15 +273,12 @@ export class RealtimeInput {
|
||||
gateVoiceVisualizerLevel(normalized, gate),
|
||||
0.72,
|
||||
);
|
||||
bar.style.setProperty(
|
||||
'--audio-level',
|
||||
`${Math.round(5 + shaped * 29)}px`,
|
||||
);
|
||||
bar.style.setProperty(
|
||||
'--audio-opacity',
|
||||
`${(0.5 + shaped * 0.5).toFixed(2)}`,
|
||||
);
|
||||
return {
|
||||
level: `${Math.round(5 + shaped * 29)}px`,
|
||||
opacity: `${(0.5 + shaped * 0.5).toFixed(2)}`,
|
||||
};
|
||||
});
|
||||
this.ui?.setBars(bars);
|
||||
this.visualizerFrame = requestAnimationFrame(render);
|
||||
};
|
||||
render();
|
||||
@@ -353,9 +348,12 @@ export class RealtimeInput {
|
||||
this.visualizerAudioContext.close().catch(() => {});
|
||||
this.visualizerAudioContext = null;
|
||||
}
|
||||
resetVoiceVisualizerBars(
|
||||
this.ui?.root?.querySelectorAll('.gev-voice-visualizer span'),
|
||||
);
|
||||
this.restVoiceVisualizer();
|
||||
}
|
||||
|
||||
/** Return the waveform to its CSS standby baseline. */
|
||||
restVoiceVisualizer() {
|
||||
this.ui?.setBars(null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -363,15 +361,15 @@ export class RealtimeInput {
|
||||
* @returns {void}
|
||||
*/
|
||||
updateVoiceButtonLabel() {
|
||||
if (!this.ui.buttonLabel) return;
|
||||
if (!this.ui) return;
|
||||
// The mic button's caption; the first-run tip names it the same way.
|
||||
this.ui.buttonLabel.textContent = t('MIC');
|
||||
if (this.ui.helpDetail) {
|
||||
this.ui.helpDetail.textContent = resolveVoiceControlHint(
|
||||
this.ui.update('mic', { text: t('MIC') });
|
||||
this.ui.update('help', {
|
||||
text: resolveVoiceControlHint(
|
||||
this.pushToTalkMode,
|
||||
this.pushToTalkKeyHeld,
|
||||
);
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
setVoiceSpeaker(speaker, { keepVisualizerSpeaker = false } = {}) {
|
||||
@@ -382,7 +380,7 @@ export class RealtimeInput {
|
||||
nextSpeaker,
|
||||
keepVisualizerSpeaker,
|
||||
);
|
||||
this.ui.root.dataset.speaker = nextSpeaker;
|
||||
this.ui.update('root', { speaker: nextSpeaker });
|
||||
if (shouldPauseRadioForVoice({ speaker: nextSpeaker }))
|
||||
this.pauseRadioForVoice();
|
||||
}
|
||||
@@ -392,10 +390,7 @@ export class RealtimeInput {
|
||||
this.pushToTalkKeyHeld = false;
|
||||
this.spaceKeyHeld = false;
|
||||
this.resetPushToTalkGesture();
|
||||
if (this.ui?.root) {
|
||||
delete this.ui.root.dataset.pushToTalk;
|
||||
delete this.ui.root.dataset.microphone;
|
||||
}
|
||||
this.ui?.update('root', { pushToTalk: null, microphone: null });
|
||||
}
|
||||
|
||||
detachBindings() {
|
||||
|
||||
@@ -172,16 +172,3 @@ export function gateVoiceVisualizerLevel(level, threshold) {
|
||||
if (cleanLevel <= cleanThreshold) return 0;
|
||||
return (cleanLevel - cleanThreshold) / (1 - cleanThreshold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the CSS-owned standby baseline for every visualizer bar.
|
||||
* @param {Iterable<HTMLElement>|null|undefined} bars
|
||||
* @returns {void}
|
||||
*/
|
||||
export function resetVoiceVisualizerBars(bars) {
|
||||
if (!bars) return;
|
||||
for (const bar of bars) {
|
||||
bar.style.removeProperty('--audio-level');
|
||||
bar.style.removeProperty('--audio-opacity');
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
import test from 'node:test';
|
||||
import { GevRealtimeController } from './realtimeController.js';
|
||||
import { PROGRESS_MAX_OUTPUT_TOKENS } from './realtimeTurns.js';
|
||||
@@ -8,12 +9,7 @@ function controllerFixture({ runner = async () => ({ ok: true }) } = {}) {
|
||||
const statuses = [];
|
||||
const usage = [];
|
||||
const controller = new GevRealtimeController({
|
||||
ui: {
|
||||
root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] },
|
||||
status: { textContent: '' },
|
||||
detail: { textContent: '', title: '' },
|
||||
errorDetail: { textContent: '' },
|
||||
},
|
||||
ui: createTestVoiceUi(),
|
||||
runner,
|
||||
debugSink: null,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
import test from 'node:test';
|
||||
import { GevRealtimeController } from './realtimeController.js';
|
||||
import { RealtimeInput } from './realtimeInput.js';
|
||||
@@ -60,7 +61,7 @@ test('retained peer and channel callbacks cannot act after stop or enter a repla
|
||||
async negotiate() { return 'answer'; },
|
||||
},
|
||||
debugSink: null,
|
||||
ui: { root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] }, status: {}, detail: {} },
|
||||
ui: createTestVoiceUi(),
|
||||
});
|
||||
await controller.start();
|
||||
const old = peers[0];
|
||||
@@ -106,7 +107,7 @@ test('an offer resolved after restart cannot change the replacement peer descrip
|
||||
async negotiate() { return 'answer'; },
|
||||
},
|
||||
debugSink: null,
|
||||
ui: { root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] }, status: {}, detail: {} },
|
||||
ui: createTestVoiceUi(),
|
||||
});
|
||||
const firstStart = controller.start();
|
||||
while (!finishOldOffer) await new Promise(resolve => setImmediate(resolve));
|
||||
@@ -170,8 +171,9 @@ test('audio meter initialization failure releases its newly acquired AudioContex
|
||||
close() { closes++; return Promise.resolve(); }
|
||||
}
|
||||
installGlobals(t, { window: { AudioContext } });
|
||||
const ui = createTestVoiceUi();
|
||||
const owner = new RealtimeInput({
|
||||
readUi: () => ({ root: { querySelectorAll: () => [{ style: { setProperty() {}, removeProperty() {} } }] } }),
|
||||
readUi: () => ui,
|
||||
readStatus: () => 'listening', readStream: () => null, operations: {},
|
||||
});
|
||||
owner.startVoiceVisualizer({});
|
||||
@@ -199,10 +201,13 @@ test('stopping one audio meter revokes retained frames without stopping another
|
||||
requestAnimationFrame: callback => { frames.push(callback); return frames.length; },
|
||||
cancelAnimationFrame() {},
|
||||
});
|
||||
const create = () => new RealtimeInput({
|
||||
readUi: () => ({ root: { querySelectorAll: () => [{ style: { setProperty() {}, removeProperty() {} } }] } }),
|
||||
readStatus: () => 'listening', readStream: () => null, operations: {},
|
||||
});
|
||||
const create = () => {
|
||||
const ui = createTestVoiceUi();
|
||||
return new RealtimeInput({
|
||||
readUi: () => ui,
|
||||
readStatus: () => 'listening', readStream: () => null, operations: {},
|
||||
});
|
||||
};
|
||||
const first = create();
|
||||
const second = create();
|
||||
first.startVoiceVisualizer({}); second.startVoiceVisualizer({});
|
||||
@@ -236,7 +241,7 @@ test('late action or viewport completion cannot resume a stopped or replacement
|
||||
async negotiate() { return 'answer'; },
|
||||
},
|
||||
debugSink: null,
|
||||
ui: { root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] }, status: {}, detail: {} },
|
||||
ui: createTestVoiceUi(),
|
||||
});
|
||||
if (phase === 'viewport') controller._viewport.capture = () => new Promise(resolve => {
|
||||
finish = () => resolve('data:image/jpeg;base64,abc');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { GevRealtimeController } from './realtimeController.js';
|
||||
@@ -17,17 +18,7 @@ function fixture(t) {
|
||||
corrections = [],
|
||||
states = [];
|
||||
const controller = new GevRealtimeController({
|
||||
ui: {
|
||||
root: {
|
||||
dataset: {},
|
||||
classList: { remove() {} },
|
||||
querySelectorAll: () => [],
|
||||
remove() {},
|
||||
},
|
||||
status: {},
|
||||
detail: {},
|
||||
errorDetail: {},
|
||||
},
|
||||
ui: createTestVoiceUi(),
|
||||
runner: async () => ({ ok: false }),
|
||||
debugSink: null,
|
||||
onSessionEvent: (event) => states.push(event),
|
||||
|
||||
@@ -22,13 +22,10 @@ export function createRealtimeSession({
|
||||
sendMapEvent: (event) => controller.notifyMapEvent(event),
|
||||
ignoreButtonClick: () => Boolean(controller.spaceKeyHeld),
|
||||
bindControls() {
|
||||
if (controller.ui.tierButton) {
|
||||
controller.tierHandler = () => controller.toggleVoiceTier();
|
||||
controller.ui.tierButton.addEventListener(
|
||||
'click',
|
||||
controller.tierHandler,
|
||||
);
|
||||
}
|
||||
// Kept as the remover; a full stop releases it.
|
||||
controller.tierHandler = controller.ui.on('tier', () =>
|
||||
controller.toggleVoiceTier(),
|
||||
);
|
||||
controller.syncCostUi();
|
||||
controller.bindPushToTalkShortcut();
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
import test from 'node:test';
|
||||
import { GevRealtimeController } from './realtimeController.js';
|
||||
|
||||
@@ -15,15 +16,7 @@ function fixture({ pointer = null, onUserSend = () => {} } = {}) {
|
||||
backend: {},
|
||||
debugSink: null,
|
||||
pointer,
|
||||
ui: {
|
||||
root: {
|
||||
dataset: {},
|
||||
classList: { remove() {} },
|
||||
querySelectorAll: () => [],
|
||||
},
|
||||
status: {},
|
||||
detail: {},
|
||||
},
|
||||
ui: createTestVoiceUi(),
|
||||
});
|
||||
controller.dc = {
|
||||
readyState: 'open',
|
||||
|
||||
+17
-34
@@ -10,6 +10,7 @@ import { createRealtimeSession } from './realtimeSession.js';
|
||||
import { realtimeSessionEvent } from './realtimeEvents.js';
|
||||
import { createVoiceCommands } from './sessionCommands.js';
|
||||
import { describeUnsavedWork } from '../i18n/index.js';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
@@ -242,26 +243,17 @@ test('common button and annotation bindings work with an alternate adapter and c
|
||||
const previous = globalThis.window;
|
||||
globalThis.window = {};
|
||||
try {
|
||||
const button = new EventTarget();
|
||||
button.setAttribute = () => {};
|
||||
let removed = 0;
|
||||
let subscribed = null;
|
||||
let calls = 0;
|
||||
const events = [];
|
||||
const ui = {
|
||||
button,
|
||||
root: {
|
||||
dataset: {},
|
||||
remove() {
|
||||
removed++;
|
||||
},
|
||||
},
|
||||
status: {},
|
||||
detail: {},
|
||||
tierButton: {},
|
||||
costValue: {},
|
||||
helpDetail: {},
|
||||
const ui = createTestVoiceUi();
|
||||
// Count removals but keep the control, so a click after cleanup still
|
||||
// reaches any listener the binder failed to release.
|
||||
ui.remove = () => {
|
||||
removed++;
|
||||
};
|
||||
const click = () => ui.emit('button');
|
||||
const lifetime = new AbortController();
|
||||
const controls = createVoiceCommands({
|
||||
runner: async () => {
|
||||
@@ -294,31 +286,31 @@ test('common button and annotation bindings work with an alternate adapter and c
|
||||
};
|
||||
},
|
||||
});
|
||||
button.dispatchEvent(new Event('click'));
|
||||
click();
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(ui.tierButton.hidden, true);
|
||||
assert.equal(ui.costValue.hidden, true);
|
||||
assert.equal(ui.helpDetail.textContent, 'Activate to toggle voice');
|
||||
assert.equal(ui.status.textContent, 'LISTENING');
|
||||
assert.equal(ui.detail.textContent, 'Ready');
|
||||
assert.equal(ui.state.tier.hidden, true);
|
||||
assert.equal(ui.state.cost.hidden, true);
|
||||
assert.equal(ui.state.help.text, 'Activate to toggle voice');
|
||||
assert.equal(ui.state.status.text, 'LISTENING');
|
||||
assert.equal(ui.state.detail.text, 'Ready');
|
||||
// Switching language (a reload) asks first during a session.
|
||||
assert.deepEqual(describeUnsavedWork(), ['the active voice session']);
|
||||
subscribed({ status: 'resolved' });
|
||||
assert.deepEqual(events, [
|
||||
{ type: 'map_annotation_outline', status: 'resolved' },
|
||||
]);
|
||||
button.dispatchEvent(new Event('click'));
|
||||
click();
|
||||
assert.equal(controls.state, 'idle');
|
||||
assert.deepEqual(describeUnsavedWork(), []);
|
||||
button.dispatchEvent(new Event('click'));
|
||||
click();
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
assert.deepEqual(describeUnsavedWork(), ['the active voice session']);
|
||||
lifetime.abort();
|
||||
assert.deepEqual(describeUnsavedWork(), [], 'disposal unregisters');
|
||||
assert.equal(subscribed, null);
|
||||
assert.equal(removed, 1);
|
||||
button.dispatchEvent(new Event('click'));
|
||||
click();
|
||||
assert.equal(calls, 2);
|
||||
} finally {
|
||||
globalThis.window = previous;
|
||||
@@ -598,16 +590,7 @@ for (const emitsIdle of [false, true]) {
|
||||
|
||||
test('actual default OpenAI adapter stop publishes one common idle', (t) => {
|
||||
const events = [];
|
||||
const ui = {
|
||||
root: {
|
||||
dataset: {},
|
||||
querySelectorAll: () => [],
|
||||
classList: { remove() {} },
|
||||
remove() {},
|
||||
},
|
||||
status: {},
|
||||
detail: {},
|
||||
};
|
||||
const ui = createTestVoiceUi();
|
||||
const runner = async () => ({});
|
||||
const session = createVoiceSession({
|
||||
runner,
|
||||
|
||||
@@ -40,10 +40,7 @@ export function createVoiceCommands({
|
||||
nativePanelProvider = null,
|
||||
}) {
|
||||
if (resetExisting) window.__gevVoiceCommands?.stop?.({ removeUi: true });
|
||||
const ui = createControl({
|
||||
reset: true,
|
||||
...(panelHost ? { panel: true } : {}),
|
||||
});
|
||||
const ui = createControl(panelHost ? { panel: true } : {});
|
||||
let actionObserver;
|
||||
let panelMount;
|
||||
const session = createVoiceSession({
|
||||
@@ -67,40 +64,37 @@ export function createVoiceCommands({
|
||||
});
|
||||
const adapter = session.adapter;
|
||||
const capabilities = adapter.capabilities || {};
|
||||
const providerHandler = () => onProviderChange?.(ui.providerSelect.value);
|
||||
if (ui.providerSelect && onProviderChange) {
|
||||
if (supportedProviders && ui.providerSelect.replaceChildren) {
|
||||
const doc = ui.providerSelect.ownerDocument;
|
||||
ui.providerSelect.replaceChildren(
|
||||
...supportedProviders.map((name) => {
|
||||
const option = doc.createElement('option');
|
||||
option.value = name;
|
||||
option.textContent = VOICE_PROVIDERS[name].label;
|
||||
return option;
|
||||
}),
|
||||
);
|
||||
}
|
||||
ui.providerSelect.value = provider;
|
||||
ui.providerSelect.title = [
|
||||
t('Changing provider reconnects voice if it is on.'),
|
||||
capabilities.advisoryLimit,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
ui.providerSelect.addEventListener('change', providerHandler);
|
||||
ui.root.dataset.provider = provider;
|
||||
const offProvider = onProviderChange
|
||||
? ui.on('provider-change', (value) => onProviderChange(value))
|
||||
: null;
|
||||
if (onProviderChange) {
|
||||
ui.update('provider', {
|
||||
...(supportedProviders && {
|
||||
options: supportedProviders.map((name) => ({
|
||||
value: name,
|
||||
label: VOICE_PROVIDERS[name].label,
|
||||
})),
|
||||
}),
|
||||
value: provider,
|
||||
title: [
|
||||
t('Changing provider reconnects voice if it is on.'),
|
||||
capabilities.advisoryLimit,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
});
|
||||
ui.update('root', { provider });
|
||||
}
|
||||
const hasProviderChoice = Boolean(
|
||||
onProviderChange && supportedProviders?.length > 1,
|
||||
);
|
||||
if (ui.providerField) ui.providerField.hidden = !hasProviderChoice;
|
||||
ui.root.dataset.providerChoice = String(hasProviderChoice);
|
||||
if (ui.tierButton) ui.tierButton.hidden = !capabilities.costControls;
|
||||
if (ui.costValue) ui.costValue.hidden = !capabilities.costControls;
|
||||
ui.update('provider', { hidden: !hasProviderChoice });
|
||||
ui.update('root', { providerChoice: String(hasProviderChoice) });
|
||||
ui.update('tier', { hidden: !capabilities.costControls });
|
||||
ui.update('cost', { hidden: !capabilities.costControls });
|
||||
if (!capabilities.pushToTalk) {
|
||||
ui.button.setAttribute('aria-label', t('Toggle voice control'));
|
||||
if (ui.helpDetail)
|
||||
ui.helpDetail.textContent = t('Activate to toggle voice');
|
||||
ui.update('button', { label: t('Toggle voice control') });
|
||||
ui.update('help', { text: t('Activate to toggle voice') });
|
||||
}
|
||||
// Retain the existing controller's inspection surface for browser tools.
|
||||
const controls = adapter.controller || session;
|
||||
@@ -108,31 +102,33 @@ export function createVoiceCommands({
|
||||
controls.ui = ui;
|
||||
const updateStatus = session.subscribe((event) => {
|
||||
if (event.type !== 'state') return;
|
||||
ui.root.dataset.status = event.state;
|
||||
ui.update('root', {
|
||||
status: event.state,
|
||||
...(event.state === 'error' && { errorDismissed: false }),
|
||||
});
|
||||
// States are machine values; only the label is translated.
|
||||
ui.status.textContent = voiceStatusLabel(event.state);
|
||||
ui.update('status', { text: voiceStatusLabel(event.state) });
|
||||
// The portable session reports 'Voice off' in English: show the idle
|
||||
// label instead. Other details are provider messages, shown as they come.
|
||||
const idle = event.state === 'idle';
|
||||
const detail = event.detail === 'Voice off' ? '' : event.detail;
|
||||
ui.detail.textContent =
|
||||
detail || (idle ? t('Voice off') : t('Voice active'));
|
||||
ui.button.setAttribute('aria-pressed', String(session.isActive()));
|
||||
if (ui.errorDetail)
|
||||
ui.errorDetail.textContent =
|
||||
ui.update('detail', {
|
||||
text: detail || (idle ? t('Voice off') : t('Voice active')),
|
||||
});
|
||||
ui.update('button', { pressed: session.isActive() });
|
||||
ui.update('error', {
|
||||
text:
|
||||
event.state === 'error'
|
||||
? event.detail || t('Voice could not be started.')
|
||||
: '';
|
||||
if (event.state === 'error') ui.root.classList?.remove('error-dismissed');
|
||||
: '',
|
||||
});
|
||||
});
|
||||
// Captions, plan and result card nested in the control; it only reads
|
||||
// provider-neutral session events.
|
||||
const card = ui.card
|
||||
? createCard({
|
||||
root: ui.root,
|
||||
...(panelHost ? { onRender: () => panelMount?.panel.sync() } : {}),
|
||||
})
|
||||
: null;
|
||||
const card = createCard({
|
||||
ui,
|
||||
...(panelHost ? { onRender: () => panelMount?.panel.sync() } : {}),
|
||||
});
|
||||
panelMount = panelHost?.mount({
|
||||
ui,
|
||||
card,
|
||||
@@ -187,13 +183,13 @@ export function createVoiceCommands({
|
||||
panelMount?.closeAfterStop(ticket);
|
||||
} else void session.start({ pushToTalk: false });
|
||||
};
|
||||
ui.button.addEventListener('click', buttonHandler);
|
||||
const offButton = ui.on('button', buttonHandler);
|
||||
let cleaned = false;
|
||||
const cleanup = () => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
ui.button.removeEventListener('click', buttonHandler);
|
||||
ui.providerSelect?.removeEventListener('change', providerHandler);
|
||||
offButton();
|
||||
offProvider?.();
|
||||
unregisterUnsavedWork();
|
||||
annotationUnsubscribe?.();
|
||||
updateStatus();
|
||||
@@ -206,7 +202,7 @@ export function createVoiceCommands({
|
||||
reticle?.destroy();
|
||||
card?.destroy();
|
||||
if (disposeRunner) runner.dispose?.();
|
||||
ui.root.remove();
|
||||
ui.remove();
|
||||
};
|
||||
session.signal.addEventListener('abort', cleanup, { once: true });
|
||||
if (session.disposed) cleanup();
|
||||
|
||||
+19
-129
@@ -7,79 +7,31 @@ import {
|
||||
/** Hide an idle card this long after its last update. */
|
||||
const VOICE_CARD_IDLE_MS = 14000;
|
||||
|
||||
const ELEMENT_IDS = Object.freeze({
|
||||
card: 'gev-voice-card',
|
||||
phase: 'gev-voice-card-phase',
|
||||
dismiss: 'gev-voice-card-dismiss',
|
||||
user: 'gev-voice-card-user',
|
||||
userText: 'gev-voice-card-user-text',
|
||||
userWho: 'gev-voice-card-user-who',
|
||||
pointer: 'gev-voice-card-pointer',
|
||||
pointerKind: 'gev-voice-card-pointer-kind',
|
||||
pointerText: 'gev-voice-card-pointer-text',
|
||||
assistant: 'gev-voice-card-assistant',
|
||||
assistantText: 'gev-voice-card-assistant-text',
|
||||
plan: 'gev-voice-card-plan',
|
||||
result: 'gev-voice-card-result',
|
||||
resultTitle: 'gev-voice-card-result-title',
|
||||
chips: 'gev-voice-card-chips',
|
||||
lines: 'gev-voice-card-lines',
|
||||
referents: 'gev-voice-card-referents',
|
||||
notes: 'gev-voice-card-notes',
|
||||
noteList: 'gev-voice-card-note-list',
|
||||
live: 'gev-voice-card-live',
|
||||
});
|
||||
|
||||
function setText(element, text) {
|
||||
if (element && element.textContent !== text) element.textContent = text;
|
||||
}
|
||||
|
||||
function setHidden(element, hidden) {
|
||||
if (element && element.hidden !== hidden) element.hidden = hidden;
|
||||
}
|
||||
|
||||
function fillList(list, items, render) {
|
||||
if (!list) return;
|
||||
const doc = list.ownerDocument;
|
||||
list.replaceChildren(
|
||||
...items.map((item) => {
|
||||
const node = doc.createElement('li');
|
||||
render(node, item, doc);
|
||||
return node;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Voice card controls, nested in the voice control's markup (`control.js`)
|
||||
* Voice card controls. The card is part of the voice control (`control.js`),
|
||||
* beside its help and error trays, so Clean-UI and recording mode hide it with
|
||||
* the control. Receives provider-neutral session events through `handle` and
|
||||
* annotation outline outcomes through `outline`; owns only its listeners and
|
||||
* idle timer. All text is set through textContent. Captions update silently;
|
||||
* the polite live region carries final replies and result titles only.
|
||||
* annotation outline outcomes through `outline`, and presents the card's view
|
||||
* on the control; owns only its listener and idle timer. Captions update
|
||||
* silently; the polite live region carries final replies and result titles
|
||||
* only.
|
||||
*/
|
||||
export class VoiceCardControls {
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {HTMLElement} options.root Voice control root containing the card.
|
||||
* @param {object} options.ui The voice control presenter.
|
||||
* @param {number} [options.idleMs]
|
||||
* @param {Function} [options.setTimer]
|
||||
* @param {Function} [options.clearTimer]
|
||||
*/
|
||||
constructor({
|
||||
root,
|
||||
ui,
|
||||
idleMs = VOICE_CARD_IDLE_MS,
|
||||
setTimer = (fn, ms) => setTimeout(fn, ms),
|
||||
clearTimer = (id) => clearTimeout(id),
|
||||
onRender,
|
||||
}) {
|
||||
Object.assign(this, { idleMs, setTimer, clearTimer, onRender });
|
||||
this.elements = Object.fromEntries(
|
||||
Object.entries(ELEMENT_IDS).map(([key, id]) => [
|
||||
key,
|
||||
root?.querySelector?.(`#${id}`) || null,
|
||||
]),
|
||||
);
|
||||
Object.assign(this, { ui, idleMs, setTimer, clearTimer, onRender });
|
||||
this.state = initialVoiceCardState();
|
||||
this.enabled = true;
|
||||
this.presented = true;
|
||||
@@ -87,19 +39,16 @@ export class VoiceCardControls {
|
||||
this.consumedPresentationAnnouncement = -1;
|
||||
this.hideTimer = null;
|
||||
this.destroyed = false;
|
||||
this.listeners = new AbortController();
|
||||
if (!this.elements.card) return;
|
||||
this.elements.dismiss?.addEventListener(
|
||||
'click',
|
||||
() => this.handle({ type: 'dismiss' }),
|
||||
{ signal: this.listeners.signal },
|
||||
if (!ui) return;
|
||||
this.offDismiss = ui.on('card-dismiss', () =>
|
||||
this.handle({ type: 'dismiss' }),
|
||||
);
|
||||
this.render();
|
||||
}
|
||||
|
||||
/** Apply one session event (or `outline`/`dismiss`). */
|
||||
handle(event) {
|
||||
if (this.destroyed || !this.elements.card) return;
|
||||
if (this.destroyed || !this.ui) return;
|
||||
const next = reduceVoiceCard(this.state, event);
|
||||
if (next === this.state) return;
|
||||
if (
|
||||
@@ -123,7 +72,7 @@ export class VoiceCardControls {
|
||||
|
||||
/** A local submission allows future output after dismissal; keep its content. */
|
||||
rearm() {
|
||||
if (this.destroyed || !this.elements.card) return;
|
||||
if (this.destroyed || !this.ui) return;
|
||||
this.state = { ...this.state, dismissed: false };
|
||||
}
|
||||
|
||||
@@ -134,15 +83,14 @@ export class VoiceCardControls {
|
||||
|
||||
/** Change presentation only; session events and the idle deadline continue. */
|
||||
setEnabled(enabled) {
|
||||
if (this.destroyed || !this.elements.card) return;
|
||||
if (this.destroyed || !this.ui) return;
|
||||
this.enabled = Boolean(enabled);
|
||||
this.render();
|
||||
}
|
||||
|
||||
/** View selection changes presentation without changing the saved preference. */
|
||||
setPresented(presented) {
|
||||
if (this.destroyed || !this.elements.card || this.presented === presented)
|
||||
return;
|
||||
if (this.destroyed || !this.ui || this.presented === presented) return;
|
||||
if (!presented)
|
||||
this.consumedPresentationAnnouncement = this.announcementRevision;
|
||||
this.presented = presented;
|
||||
@@ -155,7 +103,7 @@ export class VoiceCardControls {
|
||||
this.hideTimer = null;
|
||||
if (this.destroyed) return;
|
||||
// Keep the card while the pointer or focus is inside it.
|
||||
if (this.elements.card.matches?.(':hover, :focus-within')) {
|
||||
if (this.ui.element('card')?.matches?.(':hover, :focus-within')) {
|
||||
this.scheduleIdle();
|
||||
return;
|
||||
}
|
||||
@@ -170,65 +118,7 @@ export class VoiceCardControls {
|
||||
}
|
||||
|
||||
render() {
|
||||
const el = this.elements;
|
||||
const view = this.snapshot();
|
||||
setHidden(el.card, !view.visible);
|
||||
el.card.dataset.busy = String(view.busy);
|
||||
setText(el.phase, view.phase);
|
||||
setHidden(el.user, !view.user.visible);
|
||||
setText(el.userText, view.user.text);
|
||||
setText(el.userWho, view.user.label);
|
||||
setHidden(el.pointer, !view.pointer.visible);
|
||||
setText(el.pointerKind, view.pointer.kind);
|
||||
setText(el.pointerText, view.pointer.text);
|
||||
setHidden(el.assistant, !view.assistant.visible);
|
||||
setText(el.assistantText, view.assistant.text);
|
||||
setHidden(el.plan, view.steps.length === 0);
|
||||
fillList(el.plan, view.steps, (node, step, doc) => {
|
||||
node.className = 'gev-voice-card-step';
|
||||
node.dataset.status = step.status;
|
||||
const label = doc.createElement('span');
|
||||
label.className = 'gev-voice-card-step-label';
|
||||
label.textContent = step.label;
|
||||
const status = doc.createElement('span');
|
||||
status.className = 'gev-voice-card-sr';
|
||||
status.textContent = step.statusText ? `, ${step.statusText}` : '';
|
||||
node.append(label, status);
|
||||
if (step.detail) {
|
||||
const detail = doc.createElement('span');
|
||||
detail.className = 'gev-voice-card-step-detail';
|
||||
detail.textContent = step.detail;
|
||||
node.append(detail);
|
||||
}
|
||||
});
|
||||
setHidden(el.result, !view.result.visible);
|
||||
setText(el.resultTitle, view.result.title);
|
||||
setHidden(el.chips, view.result.chips.length === 0);
|
||||
if (el.chips) {
|
||||
const doc = el.chips.ownerDocument;
|
||||
el.chips.replaceChildren(
|
||||
...view.result.chips.map((text) => {
|
||||
const chip = doc.createElement('span');
|
||||
chip.className = 'gev-voice-card-chip';
|
||||
chip.dataset.tone = text;
|
||||
chip.textContent = text;
|
||||
return chip;
|
||||
}),
|
||||
);
|
||||
}
|
||||
fillList(el.lines, view.result.lines, (node, line) => {
|
||||
node.textContent = line;
|
||||
});
|
||||
setHidden(el.referents, view.result.referents.length === 0);
|
||||
fillList(el.referents, view.result.referents, (node, ref) => {
|
||||
node.value = ref.n;
|
||||
node.textContent = ref.label;
|
||||
});
|
||||
setHidden(el.notes, view.result.notes.length === 0);
|
||||
fillList(el.noteList, view.result.notes, (node, note) => {
|
||||
node.textContent = note;
|
||||
});
|
||||
setText(el.live, view.announce);
|
||||
this.ui.replace('card', this.snapshot());
|
||||
this.onRender?.();
|
||||
}
|
||||
|
||||
@@ -246,11 +136,11 @@ export class VoiceCardControls {
|
||||
: { ...view, visible: false, announce: '' };
|
||||
}
|
||||
|
||||
/** Idempotent: removes listeners and the idle timer; markup stays owned by the control. */
|
||||
/** Idempotent: removes the listener and the idle timer; the control stays. */
|
||||
destroy() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.cancelIdle();
|
||||
this.listeners.abort();
|
||||
this.offDismiss?.();
|
||||
}
|
||||
}
|
||||
+85
-119
@@ -1,6 +1,9 @@
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { nextTick } from 'vue';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
import { mountVoiceView } from '../testSupport/voiceView.mjs';
|
||||
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
|
||||
import { VoiceCardControls } from './voiceCard.js';
|
||||
import { getLocale, setLocale } from '../i18n/index.js';
|
||||
@@ -10,10 +13,6 @@ import {
|
||||
voiceCardView,
|
||||
} from './voiceCardPresentation.js';
|
||||
|
||||
const controlSource = readFileSync(
|
||||
new URL('./control.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const css = readStylesheet(new URL('../../style.css', import.meta.url));
|
||||
|
||||
function fold(events) {
|
||||
@@ -157,33 +156,46 @@ test('presentation: silent lookups, dismissal and session end', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('markup: the card nests inside the voice control with every element and a polite live region', () => {
|
||||
const root = controlSource.indexOf("root.id = 'gev-voice-control'");
|
||||
const card = controlSource.indexOf('id="gev-voice-card"');
|
||||
assert.ok(
|
||||
root >= 0 && card > root,
|
||||
'the card is part of the voice control markup',
|
||||
);
|
||||
for (const id of [
|
||||
'gev-voice-card-phase',
|
||||
'gev-voice-card-dismiss',
|
||||
'gev-voice-card-user-text',
|
||||
'gev-voice-card-user-who',
|
||||
'gev-voice-card-assistant-text',
|
||||
'gev-voice-card-plan',
|
||||
'gev-voice-card-result-title',
|
||||
'gev-voice-card-chips',
|
||||
'gev-voice-card-lines',
|
||||
'gev-voice-card-referents',
|
||||
'gev-voice-card-note-list',
|
||||
]) {
|
||||
assert.match(controlSource, new RegExp(`id="${id}"`), `${id} is missing`);
|
||||
test('markup: the card nests inside the voice control with every element and a polite live region', async (t) => {
|
||||
const { pinia } = mountVoiceView(t);
|
||||
for (const panel of [false, true]) {
|
||||
const ui = createTestVoiceUi({ panel, pinia });
|
||||
await nextTick();
|
||||
const card = document.getElementById('gev-voice-card');
|
||||
assert.equal(
|
||||
document.getElementById('gev-voice-control').contains(card),
|
||||
true,
|
||||
'the card is part of the voice control',
|
||||
);
|
||||
assert.equal(document.querySelectorAll('#gev-voice-card').length, 1);
|
||||
if (panel)
|
||||
assert.equal(card.parentElement.id, 'gev-voice-preview-panel');
|
||||
for (const id of [
|
||||
'gev-voice-card-phase',
|
||||
'gev-voice-card-dismiss',
|
||||
'gev-voice-card-user-text',
|
||||
'gev-voice-card-user-who',
|
||||
'gev-voice-card-assistant-text',
|
||||
'gev-voice-card-plan',
|
||||
'gev-voice-card-result-title',
|
||||
'gev-voice-card-chips',
|
||||
'gev-voice-card-lines',
|
||||
'gev-voice-card-referents',
|
||||
'gev-voice-card-note-list',
|
||||
])
|
||||
assert.equal(card.contains(document.getElementById(id)), true, id);
|
||||
assert.equal(
|
||||
document.getElementById('gev-voice-card-live').getAttribute('aria-live'),
|
||||
'polite',
|
||||
);
|
||||
assert.equal(
|
||||
document
|
||||
.getElementById('gev-voice-card-dismiss')
|
||||
.getAttribute('aria-label'),
|
||||
'Dismiss voice card',
|
||||
);
|
||||
ui.remove();
|
||||
}
|
||||
assert.match(
|
||||
controlSource,
|
||||
/id="gev-voice-card-live"[^>]*aria-live="polite"/,
|
||||
);
|
||||
assert.match(controlSource, /aria-label="\$\{t\('Dismiss voice card'\)\}"/);
|
||||
assert.match(css, /#gev-voice-control \.gev-voice-card \{/);
|
||||
assert.match(
|
||||
css,
|
||||
@@ -197,60 +209,31 @@ test('markup: the card nests inside the voice control with every element and a p
|
||||
);
|
||||
});
|
||||
|
||||
function fakeRoot() {
|
||||
const make = (id) => {
|
||||
const listeners = new Map();
|
||||
const node = {
|
||||
id,
|
||||
hidden: false,
|
||||
textContent: '',
|
||||
dataset: {},
|
||||
children: [],
|
||||
ownerDocument: null,
|
||||
addEventListener(type, handler, options) {
|
||||
listeners.set(type, handler);
|
||||
options?.signal?.addEventListener('abort', () =>
|
||||
listeners.delete(type),
|
||||
);
|
||||
},
|
||||
removeEventListener(type, handler) {
|
||||
if (listeners.get(type) === handler) listeners.delete(type);
|
||||
},
|
||||
fire: (type) => listeners.get(type)?.({}),
|
||||
listenerCount: () => listeners.size,
|
||||
replaceChildren(...nodes) {
|
||||
this.children = nodes;
|
||||
},
|
||||
append(...nodes) {
|
||||
this.children.push(...nodes);
|
||||
},
|
||||
matches: () => false,
|
||||
// A real control whose listeners are counted, so a test sees teardown
|
||||
// release them.
|
||||
function countedUi(options) {
|
||||
const ui = createTestVoiceUi(options);
|
||||
const on = ui.on;
|
||||
const live = new Set();
|
||||
ui.on = (name, listener) => {
|
||||
const entry = { name };
|
||||
live.add(entry);
|
||||
const off = on(name, listener);
|
||||
return () => {
|
||||
live.delete(entry);
|
||||
off();
|
||||
};
|
||||
return node;
|
||||
};
|
||||
const nodes = new Map();
|
||||
const doc = { createElement: (tag) => ({ ...make(tag), tag }) };
|
||||
const root = {
|
||||
...make('gev-voice-control'),
|
||||
querySelector(selector) {
|
||||
const id = selector.slice(1);
|
||||
if (!nodes.has(id)) {
|
||||
const node = make(id);
|
||||
node.ownerDocument = doc;
|
||||
nodes.set(id, node);
|
||||
}
|
||||
return nodes.get(id);
|
||||
},
|
||||
};
|
||||
return { root, nodes };
|
||||
return { ui, listenerCount: () => live.size };
|
||||
}
|
||||
|
||||
test('controls: render, idle hide, dismiss and idempotent teardown', () => {
|
||||
const { root, nodes } = fakeRoot();
|
||||
const { ui, listenerCount } = countedUi();
|
||||
const shown = () => ui.state.card.visible;
|
||||
const timers = new Map();
|
||||
let nextId = 1;
|
||||
const card = new VoiceCardControls({
|
||||
root,
|
||||
ui,
|
||||
idleMs: 1000,
|
||||
setTimer: (fn) => {
|
||||
const id = nextId++;
|
||||
@@ -262,7 +245,7 @@ test('controls: render, idle hide, dismiss and idempotent teardown', () => {
|
||||
},
|
||||
clearTimer: (id) => timers.delete(id),
|
||||
});
|
||||
assert.equal(nodes.get('gev-voice-card').hidden, true, 'starts hidden');
|
||||
assert.equal(shown(), false, 'starts hidden');
|
||||
card.handle({ type: 'interruption' });
|
||||
card.handle({
|
||||
type: 'action-call',
|
||||
@@ -277,33 +260,26 @@ test('controls: render, idle hide, dismiss and idempotent teardown', () => {
|
||||
display: { title: 'Tokyo', lines: [], chips: [{ label: 'stale' }] },
|
||||
},
|
||||
});
|
||||
assert.equal(nodes.get('gev-voice-card').hidden, false);
|
||||
assert.equal(nodes.get('gev-voice-card-result-title').textContent, 'Tokyo');
|
||||
assert.equal(nodes.get('gev-voice-card-plan').children.length, 1);
|
||||
assert.equal(
|
||||
nodes.get('gev-voice-card-chips').children[0].textContent,
|
||||
'stale',
|
||||
);
|
||||
assert.equal(shown(), true);
|
||||
assert.equal(ui.state.card.result.title, 'Tokyo');
|
||||
assert.equal(ui.state.card.steps.length, 1);
|
||||
assert.equal(ui.state.card.result.chips[0], 'stale');
|
||||
assert.equal(timers.size, 1, 'one idle timer at a time');
|
||||
[...timers.values()][0]();
|
||||
assert.equal(nodes.get('gev-voice-card').hidden, true, 'hides when idle');
|
||||
assert.equal(shown(), false, 'hides when idle');
|
||||
card.handle({ type: 'interruption' });
|
||||
nodes.get('gev-voice-card-dismiss').fire('click');
|
||||
assert.equal(nodes.get('gev-voice-card').hidden, true, 'dismiss hides it');
|
||||
ui.emit('card-dismiss');
|
||||
assert.equal(shown(), false, 'dismiss hides it');
|
||||
card.destroy();
|
||||
card.destroy();
|
||||
assert.equal(timers.size, 0);
|
||||
assert.equal(nodes.get('gev-voice-card-dismiss').listenerCount(), 0);
|
||||
assert.equal(listenerCount(), 0);
|
||||
card.handle({ type: 'interruption' });
|
||||
assert.equal(
|
||||
nodes.get('gev-voice-card').hidden,
|
||||
true,
|
||||
'a destroyed card ignores late events',
|
||||
);
|
||||
assert.equal(shown(), false, 'a destroyed card ignores late events');
|
||||
});
|
||||
|
||||
test('controls: a control without card markup is inert', () => {
|
||||
const card = new VoiceCardControls({ root: { querySelector: () => null } });
|
||||
test('controls: card controls without a voice control are inert', () => {
|
||||
const card = new VoiceCardControls({});
|
||||
assert.doesNotThrow(() => card.handle({ type: 'interruption' }));
|
||||
card.destroy();
|
||||
});
|
||||
@@ -433,12 +409,12 @@ test('presentation: stale and unmatched events never reopen the card', () => {
|
||||
});
|
||||
|
||||
test('presentation controls preserve real reducer content and the existing 14-second idle deadline', () => {
|
||||
const { root, nodes } = fakeRoot();
|
||||
const ui = createTestVoiceUi();
|
||||
const timers = new Map();
|
||||
let next = 0,
|
||||
renders = 0;
|
||||
const card = new VoiceCardControls({
|
||||
root,
|
||||
ui,
|
||||
setTimer(fn, ms) {
|
||||
const id = ++next;
|
||||
timers.set(id, { fn, ms });
|
||||
@@ -464,7 +440,7 @@ test('presentation controls preserve real reducer content and the existing 14-se
|
||||
assert.equal(timers.get(timer).ms, 14000);
|
||||
card.setPresented(false);
|
||||
assert.equal(card.snapshot().visible, false);
|
||||
assert.equal(nodes.get('gev-voice-card-live').textContent, '');
|
||||
assert.equal(ui.state.card.announce, '');
|
||||
card.setPresented(true);
|
||||
assert.equal(card.snapshot().visible, true);
|
||||
assert.equal(card.snapshot().assistant.text, 'A current answer');
|
||||
@@ -507,9 +483,9 @@ test('presentation controls preserve real reducer content and the existing 14-se
|
||||
});
|
||||
|
||||
test('a new hidden reply with identical words is still a fresh announcement', () => {
|
||||
const { root } = fakeRoot();
|
||||
const ui = createTestVoiceUi();
|
||||
const card = new VoiceCardControls({
|
||||
root,
|
||||
ui,
|
||||
setTimer: () => 1,
|
||||
clearTimer() {},
|
||||
});
|
||||
@@ -538,9 +514,9 @@ test('additive presentation gates retain the current localized reducer labels',
|
||||
const previous = getLocale();
|
||||
try {
|
||||
setLocale('es');
|
||||
const { root } = fakeRoot();
|
||||
const ui = createTestVoiceUi();
|
||||
const card = new VoiceCardControls({
|
||||
root,
|
||||
ui,
|
||||
setTimer: () => 1,
|
||||
clearTimer() {},
|
||||
});
|
||||
@@ -554,15 +530,15 @@ test('additive presentation gates retain the current localized reducer labels',
|
||||
assert.equal(card.snapshot().phase, 'EN CURSO');
|
||||
const steps = card.state.steps;
|
||||
const result = card.state.result;
|
||||
const who = root.querySelector('#gev-voice-card-user-who');
|
||||
const who = () => ui.state.card.user.label;
|
||||
card.handle({ type: 'transcript', role: 'user', text: 'Same words', attribution: 'unverified' });
|
||||
assert.equal(card.state.userAttribution, 'unverified');
|
||||
assert.equal(card.snapshot().user.label, 'Habla reciente');
|
||||
assert.equal(who.textContent, 'Habla reciente');
|
||||
assert.equal(who(), 'Habla reciente');
|
||||
card.handle({ type: 'transcript', role: 'user', text: 'Same words' });
|
||||
assert.equal(card.state.userAttribution, null);
|
||||
assert.equal(card.snapshot().user.label, 'TÚ');
|
||||
assert.equal(who.textContent, 'TÚ');
|
||||
assert.equal(who(), 'TÚ');
|
||||
assert.equal(card.state.steps, steps);
|
||||
assert.equal(card.state.result, result);
|
||||
|
||||
@@ -582,24 +558,14 @@ test('manual panel and preference compose with the actual card without changing
|
||||
const { createManualVoicePanel } = await import('./panel/manualPanel.js');
|
||||
const { createVoicePreferences, bindPreferenceControls } =
|
||||
await import('./panel/preferences.js');
|
||||
const { root } = fakeRoot();
|
||||
const ui = createTestVoiceUi({ panel: true });
|
||||
let panel;
|
||||
const card = new VoiceCardControls({
|
||||
root,
|
||||
ui,
|
||||
setTimer: () => 1,
|
||||
clearTimer() {},
|
||||
onRender: () => panel?.sync(),
|
||||
});
|
||||
const ui = {
|
||||
root,
|
||||
card: root.querySelector('#gev-voice-card'),
|
||||
voicePanel: root.querySelector('#panel'),
|
||||
previewPanel: root.querySelector('#preview-panel'),
|
||||
voiceSettingsPanel: root.querySelector('#settings-panel'),
|
||||
previewEmpty: root.querySelector('#empty'),
|
||||
voicePreviewInput: root.querySelector('#preview-input'),
|
||||
voicePreviewNote: root.querySelector('#preview-note'),
|
||||
};
|
||||
const preference = createVoicePreferences({ storage: null });
|
||||
const fields = bindPreferenceControls({
|
||||
ui,
|
||||
@@ -639,7 +605,7 @@ test('manual panel and preference compose with the actual card without changing
|
||||
assert.equal(card.snapshot().visible, true);
|
||||
preference.setPreviewEnabled(false);
|
||||
assert.equal(card.snapshot().visible, false);
|
||||
assert.equal(ui.previewEmpty.textContent, 'Desactivada.');
|
||||
assert.equal(ui.state.previewEmpty.text, 'Desactivada.');
|
||||
panel.select('settings');
|
||||
panel.select('preview');
|
||||
assert.equal(preference.snapshot().previewEnabled, false);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// src/voice/voiceControlView.test.mjs — the voice control renders the card's
|
||||
// lists and the waveform from its presenter, over happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
|
||||
import { mountVoiceView } from '../testSupport/voiceView.mjs';
|
||||
import { useVoiceStore } from '../ui/stores/voice.js';
|
||||
import { createManualVoicePanel } from './panel/manualPanel.js';
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const $$ = (selector) => [...document.querySelectorAll(selector)];
|
||||
|
||||
test('the card renders its plan, chips, lines, referents and notes', async (t) => {
|
||||
const { pinia, settle } = mountVoiceView(t);
|
||||
const ui = createTestVoiceUi({ pinia });
|
||||
ui.replace('card', {
|
||||
...ui.state.card,
|
||||
visible: true,
|
||||
busy: true,
|
||||
phase: 'WORKING',
|
||||
steps: [
|
||||
{
|
||||
key: 'a',
|
||||
label: 'Fly to Austin',
|
||||
status: 'done',
|
||||
statusText: 'done',
|
||||
detail: '',
|
||||
},
|
||||
{
|
||||
key: 'b',
|
||||
label: 'Show traffic',
|
||||
status: 'running',
|
||||
statusText: '',
|
||||
detail: 'Loading',
|
||||
},
|
||||
],
|
||||
result: {
|
||||
visible: true,
|
||||
title: 'Austin',
|
||||
chips: ['live'],
|
||||
lines: ['Line one', 'Line two'],
|
||||
referents: [{ n: 3, label: 'Tower' }],
|
||||
notes: ['Source: Example'],
|
||||
},
|
||||
announce: 'Austin',
|
||||
});
|
||||
await settle();
|
||||
assert.equal($('#gev-voice-card').hidden, false);
|
||||
assert.equal($('#gev-voice-card').dataset.busy, 'true');
|
||||
assert.equal($('#gev-voice-card-phase').textContent, 'WORKING');
|
||||
const steps = $$('#gev-voice-card-plan .gev-voice-card-step');
|
||||
assert.deepEqual(
|
||||
steps.map((step) => [step.dataset.status, step.textContent]),
|
||||
[
|
||||
['done', 'Fly to Austin, done'],
|
||||
['running', 'Show trafficLoading'],
|
||||
],
|
||||
);
|
||||
assert.equal($('#gev-voice-card-plan').hidden, false);
|
||||
const chip = $('#gev-voice-card-chips .gev-voice-card-chip');
|
||||
assert.equal(chip.dataset.tone, 'live');
|
||||
assert.equal(chip.textContent, 'live');
|
||||
assert.deepEqual(
|
||||
$$('#gev-voice-card-lines li').map((li) => li.textContent),
|
||||
['Line one', 'Line two'],
|
||||
);
|
||||
const referent = $('#gev-voice-card-referents li');
|
||||
assert.equal(referent.value, 3);
|
||||
assert.equal(referent.textContent, 'Tower');
|
||||
assert.equal($('#gev-voice-card-notes').hidden, false);
|
||||
assert.equal($('#gev-voice-card-live').textContent, 'Austin');
|
||||
|
||||
ui.replace('card', {
|
||||
...ui.state.card,
|
||||
steps: [],
|
||||
result: { ...ui.state.card.result, chips: [], referents: [], notes: [] },
|
||||
});
|
||||
await settle();
|
||||
assert.equal($('#gev-voice-card-plan').hidden, true);
|
||||
assert.equal($('#gev-voice-card-chips').hidden, true);
|
||||
assert.equal($('#gev-voice-card-referents').hidden, true);
|
||||
assert.equal($('#gev-voice-card-notes').hidden, true);
|
||||
});
|
||||
|
||||
test('the waveform shows bars as they change and rests to the CSS baseline', async (t) => {
|
||||
const { pinia, settle } = mountVoiceView(t);
|
||||
const ui = createTestVoiceUi({ pinia });
|
||||
await settle();
|
||||
const bars = () => $$('.gev-voice-visualizer span');
|
||||
assert.equal(bars().length, 15);
|
||||
assert.equal(bars()[4].style.getPropertyValue('--bar'), '4');
|
||||
assert.equal(bars()[4].style.getPropertyValue('--audio-level'), '');
|
||||
const frame = Array.from({ length: 15 }, (_, i) => ({
|
||||
level: `${5 + i}px`,
|
||||
opacity: '0.80',
|
||||
}));
|
||||
ui.setBars(frame);
|
||||
await settle();
|
||||
assert.equal(bars()[4].style.getPropertyValue('--audio-level'), '9px');
|
||||
assert.equal(bars()[4].style.getPropertyValue('--audio-opacity'), '0.80');
|
||||
// An identical frame publishes nothing, and bars never replace the
|
||||
// control's view, so the rest of the dock does not re-render for them.
|
||||
const store = useVoiceStore(pinia);
|
||||
const view = store.view;
|
||||
const published = store.bars;
|
||||
ui.setBars(frame.map((bar) => ({ ...bar })));
|
||||
assert.equal(store.bars === published, true);
|
||||
assert.equal(store.view === view, true);
|
||||
ui.setBars(null);
|
||||
await settle();
|
||||
assert.equal(bars()[4].style.getPropertyValue('--audio-level'), '');
|
||||
assert.equal(bars()[4].style.getPropertyValue('--bar'), '4');
|
||||
});
|
||||
|
||||
test('Preview is not reported visible while the error tray replaces the card', async (t) => {
|
||||
const { pinia, settle } = mountVoiceView(t);
|
||||
const ui = createTestVoiceUi({ panel: true, pinia });
|
||||
ui.replace('card', { ...ui.state.card, visible: true });
|
||||
const panel = createManualVoicePanel({
|
||||
ui,
|
||||
card: { setPresented() {} },
|
||||
getPreviewEnabled: () => true,
|
||||
copy: { previewEmpty: 'Empty', previewDisabled: 'Off' },
|
||||
});
|
||||
t.after(() => panel.destroy());
|
||||
panel.select('preview');
|
||||
await settle();
|
||||
assert.equal(panel.snapshot().previewVisible, true);
|
||||
ui.update('root', { status: 'error' });
|
||||
assert.equal(panel.snapshot().previewVisible, false);
|
||||
});
|
||||
Reference in new issue
Block a user