fix(frontend): make overlays accessible and wake word opt-in
This commit is contained in:
@@ -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
@@ -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} />}
|
||||||
|
|||||||
@@ -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} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user