fix(frontend): make overlays accessible and wake word opt-in

This commit is contained in:
2026-10-07 09:32:53 -03:00
parent 0c57f7c76d
commit a56e1082e5
12 changed files with 285 additions and 138 deletions
+4
View File
@@ -9,6 +9,10 @@ the implementation at the recorded date; current operating instructions live in
### Fixed ### Fixed
- Use native dialogs for startup and mobile reports, restore focus on dismissal,
and make duplicate transcript bubbles inert. Require explicit wake-word opt-in,
show when it is listening, and turn it off when the conversation stops.
- Isolate microphone meter subscriptions from the application tree. Apply draft settings only - Isolate microphone meter subscriptions from the application tree. Apply draft settings only
on Save, preserve explicitly live noise calibration, and persist/send only relevant changes. on Save, preserve explicitly live noise calibration, and persist/send only relevant changes.
+47 -103
View File
@@ -1,13 +1,15 @@
import { BookOpen, ChartNoAxesColumn, ChevronRight, Info, PanelLeft, Settings, Wrench, X } from "lucide-react"; import { BookOpen, Info, PanelLeft, Settings, Wrench, X } from "lucide-react";
import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react"; import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react";
import { Bubbles } from "./components/Bubbles"; import { Bubbles } from "./components/Bubbles";
import { Conversation } from "./components/Conversation"; import { Conversation } from "./components/Conversation";
import { Dialog } from "./components/Dialog";
import { ReportsPanel } from "./components/ReportsPanel";
import { AboutDialog, ProfileDialog, RagDialog, SettingsDialog, ToolsDialog } from "./components/SettingsDialogs"; import { AboutDialog, ProfileDialog, RagDialog, SettingsDialog, ToolsDialog } from "./components/SettingsDialogs";
import { VoiceStage } from "./components/VoiceStage"; import { VoiceStage } from "./components/VoiceStage";
import { useWakeWord } from "./hooks/use-wake-word"; import { WakeWordControl } from "./components/WakeWordControl";
import { useMediaQuery } from "./hooks/use-media-query";
import { initials, profileName } from "./lib/context"; import { initials, profileName } from "./lib/context";
import { reportSuggestions } from "./lib/reports";
import { isDebug } from "./lib/settings"; import { isDebug } from "./lib/settings";
import { ACTIVE_STATES, VoiceController } from "./realtime/controller"; import { ACTIVE_STATES, VoiceController } from "./realtime/controller";
@@ -17,6 +19,7 @@ export default function App() {
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot); const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot);
const [modal, setModal] = useState<Modal>(null); const [modal, setModal] = useState<Modal>(null);
const [reportsOpen, setReportsOpen] = useState(false); const [reportsOpen, setReportsOpen] = useState(false);
const desktop = useMediaQuery("(min-width: 1024px)");
const debug = isDebug(); const debug = isDebug();
const white = state.config.theme === "white"; const white = state.config.theme === "white";
const name = profileName(state.profile); const name = profileName(state.profile);
@@ -51,21 +54,6 @@ export default function App() {
clearTimeout(timer); clearTimeout(timer);
}; };
}, [ready, controller]); }, [ready, controller]);
useWakeWord(ready && !ACTIVE_STATES.has(state.status) && !modal && !state.unlock, controller);
useEffect(() => {
if (!reportsOpen) {
return;
}
const escape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setReportsOpen(false);
}
};
window.addEventListener("keydown", escape);
return () => {
window.removeEventListener("keydown", escape);
};
}, [reportsOpen]);
const close = () => { const close = () => {
setModal(null); setModal(null);
}; };
@@ -98,20 +86,36 @@ export default function App() {
return ( return (
<> <>
{state.unlock && ( {state.unlock && (
<button <Dialog
className="talk-unlocker" title="Listo para conversar"
type="button" onClose={() => {
onClick={() => { controller.dismissUnlock();
void controller.start();
}} }}
aria-label="Empezar a hablar"
> >
<span className="talk-unlocker-hint"> <p className="tools-intro">
<span className="talk-unlocker-spinner" aria-hidden="true" /> Activa el micrófono para hablar con Vocero. Puedes detener la conversación en cualquier momento.
<span className="talk-unlocker-title">Preparando Vocero…</span> </p>
<span className="talk-unlocker-sub">Toca en cualquier lugar para empezar a hablar</span> <footer className="modal-footer">
</span> <button
</button> className="btn secondary"
type="button"
onClick={() => {
controller.dismissUnlock();
}}
>
Ahora no
</button>
<button
className="btn primary"
type="button"
onClick={() => {
void controller.start();
}}
>
Empezar a hablar
</button>
</footer>
</Dialog>
)} )}
<div id="app" className={hasReports ? "has-reports" : ""}> <div id="app" className={hasReports ? "has-reports" : ""}>
<header className="topbar"> <header className="topbar">
@@ -141,7 +145,7 @@ export default function App() {
{initials(name)} {initials(name)}
</button> </button>
)} )}
{hasReports && ( {hasReports && !desktop && (
<button <button
id="reports-sidebar-toggle" id="reports-sidebar-toggle"
className="icon-btn" className="icon-btn"
@@ -209,27 +213,6 @@ export default function App() {
Enviar comentarios Enviar comentarios
</a> </a>
</nav> </nav>
{white && (
<div className="status-card" aria-label="Estado de la sesión">
<div className="status-card-icon" aria-hidden="true">
<ChartNoAxesColumn size={24} />
</div>
<div className="status-card-text">
<h2 className="status-card-title">Listo para conversar</h2>
<p className="status-card-sub">Informes, análisis, datos y mucho más.</p>
</div>
<button
className="status-card-arrow icon-btn"
type="button"
aria-label="Ver más detalles"
onClick={() => {
setModal("about");
}}
>
<ChevronRight size={22} />
</button>
</div>
)}
{state.error && ( {state.error && (
<div className="app-error" role="alert"> <div className="app-error" role="alert">
<span>{state.error}</span> <span>{state.error}</span>
@@ -246,60 +229,21 @@ export default function App() {
</div> </div>
)} )}
<VoiceStage state={state} controller={controller} /> <VoiceStage state={state} controller={controller} />
{ready && !ACTIVE_STATES.has(state.status) && !modal && !state.unlock && !reportsOpen && (
<WakeWordControl enabled={state.wakeWordEnabled} controller={controller} />
)}
<Conversation messages={state.messages} userName={name || "Usuario"} status={state.status} white={white} /> <Conversation messages={state.messages} userName={name || "Usuario"} status={state.status} white={white} />
</div> </div>
{!white && ACTIVE_STATES.has(state.status) && <Bubbles messages={state.messages} userName={name || "Usuario"} />} {!white && ACTIVE_STATES.has(state.status) && <Bubbles messages={state.messages} userName={name || "Usuario"} />}
{hasReports && ( {hasReports && state.reports && (
<aside <ReportsPanel
id="reports-sidebar" reports={state.reports}
className={`reports-sidebar ${reportsOpen ? "open" : ""}`} desktop={desktop}
aria-label="Reportes programados" open={reportsOpen}
> onClose={() => {
<button setReportsOpen(false);
className="reports-sidebar-backdrop" }}
type="button" />
aria-label="Cerrar panel de reportes"
tabIndex={reportsOpen ? 0 : -1}
onClick={() => {
setReportsOpen(false);
}}
/>
<div className="reports-sidebar-inner">
<header className="reports-sidebar-header">
<h2>Reportes</h2>
<button
id="reports-sidebar-close"
className="icon-btn"
type="button"
aria-label="Cerrar reportes"
onClick={() => {
setReportsOpen(false);
}}
>
<X size={18} />
</button>
</header>
<div className="reports-hint">
<p>{state.reports?.count} reportes disponibles</p>
<ul>
{state.reports?.hints.map((hint, index) => (
<li key={index}>{hint}</li>
))}
</ul>
{state.reports?.note && <p>{state.reports.note}</p>}
</div>
{state.reports && (
<div className="reports-suggestions">
<h3 className="reports-suggestions-title">Puedes preguntar</h3>
<ul className="reports-suggestions-list">
{reportSuggestions(state.reports).map((suggestion) => (
<li key={suggestion}>{suggestion}</li>
))}
</ul>
</div>
)}
</div>
</aside>
)} )}
{modal === "settings" && <SettingsDialog state={state} controller={controller} onClose={close} />} {modal === "settings" && <SettingsDialog state={state} controller={controller} onClose={close} />}
{modal === "tools" && <ToolsDialog state={state} controller={controller} onClose={close} />} {modal === "tools" && <ToolsDialog state={state} controller={controller} onClose={close} />}
+2 -2
View File
@@ -15,7 +15,7 @@ function Bubble({ message, userName }: { message: Message; userName: string }) {
clearTimeout(timer); clearTimeout(timer);
}; };
}, [message.text, message.partial]); }, [message.text, message.partial]);
if (expiredText === message.text) { if (!message.partial && expiredText === message.text) {
return null; return null;
} }
return ( return (
@@ -36,7 +36,7 @@ function Bubble({ message, userName }: { message: Message; userName: string }) {
} }
export const Bubbles = memo(function Bubbles({ messages, userName }: { messages: Message[]; userName: string }) { export const Bubbles = memo(function Bubbles({ messages, userName }: { messages: Message[]; userName: string }) {
return ( return (
<div className="bubble-stack" aria-hidden="true"> <div className="bubble-stack" aria-hidden="true" inert>
{messages.slice(-3).map((message) => ( {messages.slice(-3).map((message) => (
<Bubble key={message.id} message={message} userName={userName} /> <Bubble key={message.id} message={message} userName={userName} />
))} ))}
+6 -2
View File
@@ -1,5 +1,5 @@
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useEffect, useId, useRef, type ReactNode } from "react"; import { useLayoutEffect, useId, useRef, type ReactNode } from "react";
interface Props { interface Props {
title: string; title: string;
@@ -10,11 +10,15 @@ interface Props {
export function Dialog({ title, children, onClose, className = "" }: Props) { export function Dialog({ title, children, onClose, className = "" }: Props) {
const ref = useRef<HTMLDialogElement>(null); const ref = useRef<HTMLDialogElement>(null);
const titleId = useId(); const titleId = useId();
useEffect(() => { useLayoutEffect(() => {
const dialog = ref.current; const dialog = ref.current;
const opener = document.activeElement;
dialog?.showModal(); dialog?.showModal();
return () => { return () => {
dialog?.close(); dialog?.close();
if (opener instanceof HTMLElement && opener.isConnected) {
opener.focus({ preventScroll: true });
}
}; };
}, []); }, []);
return ( return (
@@ -0,0 +1,54 @@
import type { Reports } from "../lib/contracts";
import { reportSuggestions } from "../lib/reports";
import { Dialog } from "./Dialog";
function ReportContent({ reports }: { reports: Reports }) {
return (
<div className="reports-content">
<div className="reports-hint">
<p>{reports.count} reportes disponibles</p>
<ul>
{reports.hints.map((hint, index) => (
<li key={index}>{hint}</li>
))}
</ul>
{reports.note && <p>{reports.note}</p>}
</div>
<div className="reports-suggestions">
<h3 className="reports-suggestions-title">Puedes preguntar</h3>
<ul className="reports-suggestions-list">
{reportSuggestions(reports).map((suggestion) => (
<li key={suggestion}>{suggestion}</li>
))}
</ul>
</div>
</div>
);
}
export function ReportsPanel({
reports,
desktop,
open,
onClose,
}: {
reports: Reports;
desktop: boolean;
open: boolean;
onClose: () => void;
}) {
if (desktop) {
return (
<aside id="reports-sidebar" className="reports-panel" aria-label="Reportes programados">
<h2>Reportes</h2>
<ReportContent reports={reports} />
</aside>
);
}
return open ? (
<Dialog title="Reportes" className="reports-drawer" onClose={onClose}>
<div id="reports-sidebar">
<ReportContent reports={reports} />
</div>
</Dialog>
) : null;
}
@@ -0,0 +1,49 @@
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useSyncExternalStore } from "react";
import { afterEach, expect, it, vi } from "vitest";
import { VoiceController } from "../realtime/controller";
import { WakeWordControl } from "./WakeWordControl";
class Recognition {
static instances: Recognition[] = [];
lang = "";
continuous = false;
interimResults = false;
onstart: (() => void) | null = null;
onend: (() => void) | null = null;
onerror = null;
onresult = null;
start = vi.fn(() => {
this.onstart?.();
});
abort = vi.fn();
constructor() {
Recognition.instances.push(this);
}
}
function Control({ controller }: { controller: VoiceController }) {
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot);
return <WakeWordControl enabled={state.wakeWordEnabled} controller={controller} />;
}
afterEach(() => {
vi.unstubAllGlobals();
});
it("requires opt-in, displays actual listening, and releases recognition on Stop", async () => {
Recognition.instances = [];
vi.stubGlobal("SpeechRecognition", Recognition);
const controller = new VoiceController();
const user = userEvent.setup();
render(<Control controller={controller} />);
expect(Recognition.instances).toHaveLength(0);
await user.click(screen.getByRole("checkbox"));
expect(Recognition.instances).toHaveLength(1);
expect(screen.getByRole("status")).toHaveTextContent("Micrófono activo");
await act(async () => {
await controller.stop();
});
expect(Recognition.instances[0]?.abort).toHaveBeenCalledOnce();
expect(screen.getByRole("checkbox")).not.toBeChecked();
expect(Recognition.instances).toHaveLength(1);
});
@@ -0,0 +1,32 @@
import { useWakeWord } from "../hooks/use-wake-word";
import type { VoiceController } from "../realtime/controller";
export function WakeWordControl({ enabled, controller }: { enabled: boolean; controller: VoiceController }) {
const status = useWakeWord(enabled, controller);
if (status === "unsupported") {
return null;
}
return (
<div className="wake-word-control">
<label>
<input
type="checkbox"
checked={enabled}
onChange={(event) => {
controller.setWakeWordEnabled(event.target.checked);
}}
/>
Activar con «hola vocero»
</label>
<span role="status">
{status === "listening"
? "Micrófono activo: escuchando la frase de activación."
: status === "blocked"
? "No se pudo activar. Revisa el permiso del micrófono y vuelve a marcar la opción."
: status === "starting"
? "Preparando activación por voz…"
: "Opcional. Se desactiva al detener la conversación."}
</span>
</div>
);
}
@@ -0,0 +1,15 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
export function useMediaQuery(query: string) {
const media = useMemo(() => window.matchMedia(query), [query]);
const subscribe = useCallback(
(listener: () => void) => {
media.addEventListener("change", listener);
return () => {
media.removeEventListener("change", listener);
};
},
[media],
);
return useSyncExternalStore(subscribe, () => media.matches);
}
+13 -2
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import type { VoiceController } from "../realtime/controller"; import type { VoiceController } from "../realtime/controller";
@@ -9,6 +9,7 @@ interface Recognition {
onresult: ((event: { resultIndex: number; results: ArrayLike<ArrayLike<{ transcript: string }>> }) => void) | null; onresult: ((event: { resultIndex: number; results: ArrayLike<ArrayLike<{ transcript: string }>> }) => void) | null;
onerror: ((event: { error: string }) => void) | null; onerror: ((event: { error: string }) => void) | null;
onend: (() => void) | null; onend: (() => void) | null;
onstart: (() => void) | null;
start: () => void; start: () => void;
abort: () => void; abort: () => void;
} }
@@ -20,6 +21,8 @@ declare global {
} }
/** Optional browser wake word; never owns the microphone during a voice session. */ /** Optional browser wake word; never owns the microphone during a voice session. */
export function useWakeWord(enabled: boolean, controller: VoiceController) { export function useWakeWord(enabled: boolean, controller: VoiceController) {
const [status, setStatus] = useState<"starting" | "listening" | "blocked">("starting");
const supported = !!(window.SpeechRecognition ?? window.webkitSpeechRecognition);
useEffect(() => { useEffect(() => {
const Constructor = window.SpeechRecognition ?? window.webkitSpeechRecognition; const Constructor = window.SpeechRecognition ?? window.webkitSpeechRecognition;
if (!enabled || !Constructor) { if (!enabled || !Constructor) {
@@ -36,10 +39,14 @@ export function useWakeWord(enabled: boolean, controller: VoiceController) {
try { try {
recognition.start(); recognition.start();
} catch { } catch {
/* A browser may require a click first. */ stopped = true;
setStatus("blocked");
} }
} }
}; };
recognition.onstart = () => {
setStatus("listening");
};
recognition.onresult = (event) => { recognition.onresult = (event) => {
for (let index = event.resultIndex; index < event.results.length; index++) { for (let index = event.resultIndex; index < event.results.length; index++) {
const text = const text =
@@ -58,10 +65,12 @@ export function useWakeWord(enabled: boolean, controller: VoiceController) {
recognition.onerror = (event) => { recognition.onerror = (event) => {
if (["not-allowed", "service-not-allowed", "audio-capture"].includes(event.error)) { if (["not-allowed", "service-not-allowed", "audio-capture"].includes(event.error)) {
stopped = true; stopped = true;
setStatus("blocked");
} }
}; };
recognition.onend = () => { recognition.onend = () => {
if (!stopped) { if (!stopped) {
setStatus("starting");
timer = setTimeout(start, 1000); timer = setTimeout(start, 1000);
} }
}; };
@@ -72,7 +81,9 @@ export function useWakeWord(enabled: boolean, controller: VoiceController) {
recognition.onresult = null; recognition.onresult = null;
recognition.onerror = null; recognition.onerror = null;
recognition.onend = null; recognition.onend = null;
recognition.onstart = null;
recognition.abort(); recognition.abort();
}; };
}, [enabled, controller]); }, [enabled, controller]);
return !supported ? "unsupported" : !enabled ? "off" : status;
} }
@@ -57,6 +57,7 @@ export interface AppState {
queuePosition: number; queuePosition: number;
joinDeadline: number; joinDeadline: number;
unlock: boolean; unlock: boolean;
wakeWordEnabled: boolean;
} }
/** Observable application state; React owns rendering, this object owns session lifetime. */ /** Observable application state; React owns rendering, this object owns session lifetime. */
@@ -103,6 +104,7 @@ export class VoiceController {
queuePosition: 0, queuePosition: 0,
joinDeadline: 0, joinDeadline: 0,
unlock: false, unlock: false,
wakeWordEnabled: false,
}; };
} }
getSnapshot = () => this.state; getSnapshot = () => this.state;
@@ -261,6 +263,12 @@ export class VoiceController {
dismissError() { dismissError() {
this.update({ error: "" }); this.update({ error: "" });
} }
dismissUnlock() {
this.update({ unlock: false });
}
setWakeWordEnabled(wakeWordEnabled: boolean) {
this.update({ wakeWordEnabled });
}
offerAutoStart() { offerAutoStart() {
if (this.autoStarted || this.client || !this.state.configReady || this.state.contextLoading) { if (this.autoStarted || this.client || !this.state.configReady || this.state.contextLoading) {
return; return;
@@ -608,6 +616,7 @@ export class VoiceController {
this.updateLevel(0); this.updateLevel(0);
this.update({ this.update({
status, status,
wakeWordEnabled: false,
muted: false, muted: false,
joinDeadline: 0, joinDeadline: 0,
unlock: false, unlock: false,
+44 -29
View File
@@ -65,16 +65,6 @@
font-size: 13px; font-size: 13px;
background: var(--bg-elev); background: var(--bg-elev);
} }
.talk-unlocker {
border: 0;
width: 100%;
color: inherit;
font: inherit;
cursor: pointer;
}
.talk-unlocker-hint > span {
display: block;
}
.agent-tool-item { .agent-tool-item {
padding: 12px 0; padding: 12px 0;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
@@ -88,28 +78,53 @@
.queue-actions .circle-subcaption { .queue-actions .circle-subcaption {
flex-basis: 100%; flex-basis: 100%;
} }
.reports-sidebar-backdrop { .reports-panel {
border: 0; position: fixed;
inset: 0 auto 0 0;
width: 320px;
overflow-y: auto;
box-sizing: border-box;
padding: 20px;
background: var(--bg-elev);
border-right: 1px solid var(--border);
z-index: 3;
}
.reports-panel h2 {
margin: 0 0 16px;
font-size: 18px;
}
.reports-drawer.modal {
margin: 0;
width: min(360px, 92vw);
max-height: 100dvh;
height: 100dvh;
border-radius: 0;
}
.reports-content .reports-hint {
margin: 0;
max-height: none;
padding: 12px;
}
.wake-word-control {
display: grid;
gap: 4px;
padding: 0 16px 12px;
text-align: center;
font-size: 13px;
}
.wake-word-control label {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer; cursor: pointer;
visibility: hidden;
} }
.reports-sidebar-inner { .wake-word-control span {
visibility: hidden; font-size: 12px;
color: var(--text-dim);
} }
.reports-sidebar { html[data-theme="white"] .wake-word-control span {
pointer-events: none; color: #64748b;
}
.reports-sidebar.open .reports-sidebar-inner,
.reports-sidebar.open .reports-sidebar-backdrop {
visibility: visible;
}
@media (min-width: 1024px) {
#reports-sidebar-close {
display: none;
}
.reports-sidebar-inner {
visibility: visible;
}
} }
.bubble-body { .bubble-body {
max-height: 240px; max-height: 240px;
+10
View File
@@ -3,6 +3,16 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react"; import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest"; import { afterEach } from "vitest";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (media: string) => ({
media,
matches: false,
addEventListener: () => undefined,
removeEventListener: () => undefined,
}),
});
// jsdom has no native modal top layer; real focus/inert behavior is checked in Chromium. // jsdom has no native modal top layer; real focus/inert behavior is checked in Chromium.
Object.defineProperties(HTMLDialogElement.prototype, { Object.defineProperties(HTMLDialogElement.prototype, {
showModal: { showModal: {