fix(frontend): start conversations without a welcome modal

This commit is contained in:
2026-10-07 10:46:15 -03:00
parent c06c9194e8
commit 62c2692082
8 changed files with 162 additions and 69 deletions
+48
View File
@@ -4,6 +4,7 @@ import { StrictMode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import App from "./App";
import { VoiceController } from "./realtime/controller";
const bootstrap = { theme: "white", s2sUrl: "ws://localhost:8420/v1/realtime", apipilot: true };
beforeEach(() => {
@@ -43,6 +44,53 @@ afterEach(() => {
});
describe("React application", () => {
it.each(["TALK", "TALKFIRST"])(
"attempts %s immediately without a startup dialog under StrictMode",
async (startSessionMode) => {
const original = vi.mocked(fetch).getMockImplementation();
if (!original) {
throw new Error("Missing fetch mock");
}
vi.mocked(fetch).mockImplementation((input, init) =>
input === "/api/config"
? Promise.resolve(new Response(JSON.stringify({ ...bootstrap, startSessionMode })))
: original(input, init),
);
const start = vi.spyOn(VoiceController.prototype, "start").mockResolvedValue(undefined);
render(
<StrictMode>
<App />
</StrictMode>,
);
await waitFor(() => {
expect(start).toHaveBeenCalledExactlyOnceWith(true);
});
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
},
);
it.each(["white", "dark"])(
"shows an inline microphone fallback in the %s theme when autoplay is blocked",
async (theme) => {
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify({ ...bootstrap, theme, startSessionMode: "TALK" })),
);
const getUserMedia = vi.fn();
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
vi.stubGlobal(
"AudioContext",
class {
state = "suspended";
resume = vi.fn().mockResolvedValue(undefined);
close = vi.fn().mockResolvedValue(undefined);
},
);
render(<App />);
expect(await screen.findByText("Toca el micrófono para empezar a conversar.")).toHaveAttribute("role", "status");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Iniciar conversación de voz" })).toBeEnabled();
expect(getUserMedia).not.toHaveBeenCalled();
},
);
it("offers configuration retry without exposing a broken start control", async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error("offline"));
const user = userEvent.setup();
+4 -40
View File
@@ -3,12 +3,11 @@ import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "reac
import { Bubbles } from "./components/Bubbles";
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 { VoiceStage } from "./components/VoiceStage";
import { WakeWordControl } from "./components/WakeWordControl";
import { Button, DialogFooter, DialogIntro, IconButton } from "./components/ui";
import { Button, IconButton } from "./components/ui";
import { useMediaQuery } from "./hooks/use-media-query";
import { initials, profileName } from "./lib/context";
import { isDebug } from "./lib/settings";
@@ -45,15 +44,9 @@ export default function App() {
}
}, [state.loading, white]);
useEffect(() => {
if (!ready) {
return;
}
const timer = setTimeout(() => {
if (ready) {
controller.offerAutoStart();
}, 2000);
return () => {
clearTimeout(timer);
};
}
}, [ready, controller]);
const close = () => {
setModal(null);
@@ -88,35 +81,6 @@ export default function App() {
}
return (
<>
{state.unlock && (
<Dialog
title="Listo para conversar"
onClose={() => {
controller.dismissUnlock();
}}
>
<DialogIntro>
Activa el micrófono para hablar con Vocero. Puedes detener la conversación en cualquier momento.
</DialogIntro>
<DialogFooter>
<Button
onClick={() => {
controller.dismissUnlock();
}}
>
Ahora no
</Button>
<Button
variant="primary"
onClick={() => {
void controller.start();
}}
>
Empezar a hablar
</Button>
</DialogFooter>
</Dialog>
)}
<div id="app" className={`flex w-full min-w-0 flex-1 flex-col ${hasReports ? "lg:pl-[320px]" : ""}`}>
<header className="relative z-2 mt-[max(6px,calc(env(safe-area-inset-top,0px)*0.2))] flex w-full flex-none flex-wrap items-start justify-between gap-2 px-4 pt-4 white:static white:grid white:auto-rows-min white:grid-cols-[52px_minmax(0,1fr)_52px] white:justify-items-center white:gap-x-1 white:gap-y-0 white:px-2.5 white:pt-4 white:max-[480px]:px-1.5 dark:compact:gap-3 dark:[@media(560px<height<=780px)]:px-5 dark:[@media(560px<height<=780px)]:pt-3 dark:[@media(max-height:560px)]:px-3.5 dark:[@media(max-height:560px)]:pt-1.5">
<div className="brand-art relative flex min-w-0 flex-[1_1_360px] items-start gap-2.5 overflow-hidden rounded-panel border border-solid border-line px-3 py-2.5 text-[15px] font-semibold tracking-[0.01em] text-muted white:col-start-2 white:row-start-1 white:m-0 white:w-full white:flex-none white:justify-center white:rounded-none white:border-0 white:p-0 white:text-center compact:text-[14px]">
@@ -230,7 +194,7 @@ export default function App() {
</div>
)}
<VoiceStage state={state} controller={controller} />
{ready && !ACTIVE_STATES.has(state.status) && !modal && !state.unlock && !(reportsOpen && !desktop) && (
{ready && !ACTIVE_STATES.has(state.status) && !modal && !(reportsOpen && !desktop) && (
<WakeWordControl enabled={state.wakeWordEnabled} controller={controller} />
)}
<Conversation
@@ -210,7 +210,9 @@ export function VoiceStage({ state, controller }: { state: AppState; controller:
{caption}
</p>
{state.notice && (
<p className="m-0 max-w-[440px] text-center text-[13px] leading-[1.5] text-slate-600">{state.notice}</p>
<p className="m-0 max-w-[440px] text-center text-[13px] leading-[1.5] text-slate-600" role="status">
{state.notice}
</p>
)}
{active && (
<div
@@ -302,7 +304,11 @@ export function VoiceStage({ state, controller }: { state: AppState; controller:
<p className="circle-caption" role="status">
{caption}
</p>
{state.notice && <p className="circle-subcaption">{state.notice}</p>}
{state.notice && (
<p className="circle-subcaption" role="status">
{state.notice}
</p>
)}
</>
)}
{(status === "queued" || status === "your-turn") && (
@@ -3,8 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vite
import type { ClientOptions } from "./client";
import { VoiceController } from "./controller";
const { MockClient, closeContext } = vi.hoisted(() => {
const { MockClient, closeContext, createContext } = vi.hoisted(() => {
const closeContext = vi.fn().mockResolvedValue(undefined);
const createContext = vi.fn(() => ({ state: "running", close: closeContext }));
class MockClient {
static instances: MockClient[] = [];
readonly options: ClientOptions;
@@ -41,16 +42,17 @@ const { MockClient, closeContext } = vi.hoisted(() => {
sendToolOutput = vi.fn();
join = vi.fn();
}
return { MockClient, closeContext };
return { MockClient, closeContext, createContext };
});
vi.mock("./client", async (importOriginal) => ({ ...(await importOriginal()), RealtimeClient: MockClient }));
vi.mock("../audio/engine", () => ({ createAudioContext: () => ({ state: "running", close: closeContext }) }));
vi.mock("../audio/engine", () => ({ createAudioContext: createContext }));
let pendingReasoning: ((value: Response) => void) | undefined;
let controller: VoiceController;
let fetchMock: Mock<(path: string, init?: RequestInit) => Promise<Response>>;
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
createContext.mockReturnValue({ state: "running", close: closeContext });
MockClient.instances = [];
pendingReasoning = undefined;
vi.stubGlobal("AudioContext", vi.fn());
@@ -87,6 +89,83 @@ async function start() {
return client;
}
describe("voice application lifecycle", () => {
it.each(["TALK", "TALKFIRST"])(
"automatically starts %s once with the greeting after initialization",
async (startSessionMode) => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ startSessionMode, s2sUrl: "ws://localhost/v1/realtime" })),
);
controller.offerAutoStart();
expect(MockClient.instances).toHaveLength(0);
await controller.initialize();
controller.offerAutoStart();
controller.offerAutoStart();
expect(MockClient.instances).toHaveLength(1);
expect(MockClient.instances[0]?.options.greeting).toBe("hola vocero");
expect(controller.getSnapshot().status).toBe("listening");
await controller.stop();
controller.offerAutoStart();
expect(MockClient.instances).toHaveLength(1);
expect(controller.getSnapshot().status).toBe("closed");
},
);
it.each(["", "UNSUPPORTED"])("leaves startup manual for mode %s", async (startSessionMode) => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ startSessionMode, s2sUrl: "ws://localhost/v1/realtime" })),
);
await controller.initialize();
controller.offerAutoStart();
expect(createContext).not.toHaveBeenCalled();
expect(MockClient.instances).toHaveLength(0);
await controller.start();
expect(MockClient.instances).toHaveLength(1);
expect(MockClient.instances[0]?.options.greeting).toBeUndefined();
});
it("waits for caller context before the automatic attempt", async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ startSessionMode: "TALK", s2sUrl: "ws://localhost/v1/realtime" })),
);
let finishProfile: ((value: Response) => void) | undefined;
fetchMock.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
finishProfile = resolve;
}),
);
const initializing = controller.initialize();
await vi.advanceTimersByTimeAsync(1);
expect(controller.getSnapshot()).toMatchObject({ configReady: true, contextLoading: true });
controller.offerAutoStart();
expect(MockClient.instances).toHaveLength(0);
finishProfile?.(new Response(JSON.stringify({ result: { name: "Ana" } })));
await initializing;
controller.offerAutoStart();
expect(MockClient.instances).toHaveLength(1);
});
it.each(["TALK", "TALKFIRST"])(
"leaves a manual fallback when the browser blocks %s audio",
async (startSessionMode) => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ startSessionMode, s2sUrl: "ws://localhost/v1/realtime" })),
);
createContext.mockReturnValueOnce({ state: "suspended", close: closeContext });
await controller.initialize();
controller.offerAutoStart();
expect(closeContext).toHaveBeenCalledOnce();
expect(MockClient.instances).toHaveLength(0);
expect(controller.getSnapshot()).toMatchObject({
status: "idle",
error: "",
notice: "Toca el micrófono para empezar a conversar.",
});
controller.offerAutoStart();
expect(createContext).toHaveBeenCalledOnce();
await controller.start();
expect(MockClient.instances).toHaveLength(1);
expect(MockClient.instances[0]?.options.greeting).toBe("hola vocero");
expect(controller.getSnapshot()).toMatchObject({ status: "listening", notice: "" });
},
);
it("notifies only meter subscribers when microphone level changes", async () => {
const client = await start();
const app = vi.fn();
+3 -15
View File
@@ -56,7 +56,6 @@ export interface AppState {
rag: string;
queuePosition: number;
joinDeadline: number;
unlock: boolean;
wakeWordEnabled: boolean;
}
@@ -103,7 +102,6 @@ export class VoiceController {
rag: "",
queuePosition: 0,
joinDeadline: 0,
unlock: false,
wakeWordEnabled: false,
};
}
@@ -263,9 +261,6 @@ export class VoiceController {
dismissError() {
this.update({ error: "" });
}
dismissUnlock() {
this.update({ unlock: false });
}
setWakeWordEnabled(wakeWordEnabled: boolean) {
this.update({ wakeWordEnabled });
}
@@ -275,9 +270,7 @@ export class VoiceController {
}
this.autoStarted = true;
const mode = this.state.config.startSessionMode.trim().toUpperCase();
if (mode === "TALK") {
this.update({ unlock: true });
} else if (mode === "TALKFIRST") {
if (mode === "TALK" || mode === "TALKFIRST") {
void this.start(true);
}
}
@@ -289,7 +282,6 @@ export class VoiceController {
return;
}
this.update({
unlock: false,
error: "",
notice: "",
status: "connecting",
@@ -304,8 +296,8 @@ export class VoiceController {
const target = connectionTarget(this.state.config, this.state.settings, this.caller);
context = preparedContext ?? createAudioContext();
if (automatic && context.state !== "running") {
await context.close();
this.update({ status: "idle", unlock: true });
void context.close().catch(() => undefined);
this.update({ status: "idle", notice: "Toca el micrófono para empezar a conversar." });
return;
}
this.requests = new AbortController();
@@ -417,9 +409,6 @@ export class VoiceController {
// A closed event may already have cleared the client; explicit cancellation is silent.
if (!(error instanceof SessionError && error.code === "aborted") && (!this.client || this.client === client)) {
this.reportError(error);
if (automatic) {
this.update({ unlock: true });
}
}
}
} catch (error) {
@@ -619,7 +608,6 @@ export class VoiceController {
wakeWordEnabled: false,
muted: false,
joinDeadline: 0,
unlock: false,
messages: this.state.messages.map((message) =>
message.partial ? { ...message, partial: false, interrupted: message.role === "assistant" } : message,
),