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
+5 -4
View File
@@ -113,10 +113,11 @@ before changing audio timing based on those observations.
In `OPENAI`, check the configured endpoint/model/key and provider logs; HTTP In `OPENAI`, check the configured endpoint/model/key and provider logs; HTTP
reasoning does not use the native session's tool array. reasoning does not use the native session's tool array.
For `TALK`/`TALKFIRST`, the two-second warmup does not override browser `TALK`/`TALKFIRST` attempt startup as soon as configuration and caller context
microphone/audio permissions. If automatic startup fails, use the unlocker are ready. Browser microphone/audio policies can still require a gesture. If
gesture and inspect the permission error rather than increasing the timer audio is blocked, tap the main microphone button when the inline hint appears.
without evidence. If microphone access fails, allow it in the browser's site permissions and use
the same button to retry. No startup dialog is shown.
## Local validation ## Local validation
+8 -5
View File
@@ -148,12 +148,15 @@ loading window before the interface allows a generic session.
| Value | Behavior | | Value | Behavior |
|---|---| |---|---|
| Empty/default | User taps to start | | Empty/default | User taps to start |
| `TALK` | After a 2-second warmup, show a dismissible startup dialog; a user gesture starts the session and greeting | | `TALK` | Attempt automatic start and inject the Spanish wake phrase as soon as configuration and caller context are ready |
| `TALKFIRST` | After a 2-second warmup, attempt automatic start and inject the Spanish wake phrase; microphone failure falls back to the startup dialog | | `TALKFIRST` | Same automatic startup behavior as `TALK` |
The warmup starts after configuration and caller-context loading settle; Automatic startup runs once per page load, without a startup dialog or artificial
optional reports and tools can still be loading. Browser permission and audio delay; optional reports and tools can still be loading. Browser permission and
policies can still require a gesture. audio policies can still require a gesture. When audio is blocked, an inline hint
asks the user to tap the microphone button. Permission or connection failures
show the error and leave the same button available to retry. Stopping a session
does not start it again automatically.
The greeting uses the same `hola vocero` input as wake-word handling. The greeting uses the same `hola vocero` input as wake-word handling.
On browsers with speech recognition, **Activar con «hola vocero»** opts into On browsers with speech recognition, **Activar con «hola vocero»** opts into
+4
View File
@@ -31,6 +31,10 @@ the implementation at the recorded date; current operating instructions live in
### Changed ### Changed
- Start `TALK` and `TALKFIRST` conversations as soon as configuration and caller
context are ready. Remove the startup dialog and two-second delay; use an
inline hint and the existing microphone button when a browser requires a tap.
- Replace the frontend legacy stylesheet with Tailwind v4 semantic theme tokens, - Replace the frontend legacy stylesheet with Tailwind v4 semantic theme tokens,
shared form/dialog controls and component-owned layout utilities. Preserve both shared form/dialog controls and component-owned layout utilities. Preserve both
configured themes, responsive layouts, sanitized Markdown and audio-driven configured themes, responsive layouts, sanitized Markdown and audio-driven
+48
View File
@@ -4,6 +4,7 @@ import { StrictMode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import App from "./App"; import App from "./App";
import { VoiceController } from "./realtime/controller";
const bootstrap = { theme: "white", s2sUrl: "ws://localhost:8420/v1/realtime", apipilot: true }; const bootstrap = { theme: "white", s2sUrl: "ws://localhost:8420/v1/realtime", apipilot: true };
beforeEach(() => { beforeEach(() => {
@@ -43,6 +44,53 @@ afterEach(() => {
}); });
describe("React application", () => { 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 () => { it("offers configuration retry without exposing a broken start control", async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error("offline")); vi.mocked(fetch).mockRejectedValueOnce(new Error("offline"));
const user = userEvent.setup(); 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 { 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 { 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 { WakeWordControl } from "./components/WakeWordControl"; 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 { useMediaQuery } from "./hooks/use-media-query";
import { initials, profileName } from "./lib/context"; import { initials, profileName } from "./lib/context";
import { isDebug } from "./lib/settings"; import { isDebug } from "./lib/settings";
@@ -45,15 +44,9 @@ export default function App() {
} }
}, [state.loading, white]); }, [state.loading, white]);
useEffect(() => { useEffect(() => {
if (!ready) { if (ready) {
return;
}
const timer = setTimeout(() => {
controller.offerAutoStart(); controller.offerAutoStart();
}, 2000); }
return () => {
clearTimeout(timer);
};
}, [ready, controller]); }, [ready, controller]);
const close = () => { const close = () => {
setModal(null); setModal(null);
@@ -88,35 +81,6 @@ export default function App() {
} }
return ( 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]" : ""}`}> <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"> <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]"> <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> </div>
)} )}
<VoiceStage state={state} controller={controller} /> <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} /> <WakeWordControl enabled={state.wakeWordEnabled} controller={controller} />
)} )}
<Conversation <Conversation
@@ -210,7 +210,9 @@ export function VoiceStage({ state, controller }: { state: AppState; controller:
{caption} {caption}
</p> </p>
{state.notice && ( {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 && ( {active && (
<div <div
@@ -302,7 +304,11 @@ export function VoiceStage({ state, controller }: { state: AppState; controller:
<p className="circle-caption" role="status"> <p className="circle-caption" role="status">
{caption} {caption}
</p> </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") && ( {(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 type { ClientOptions } from "./client";
import { VoiceController } from "./controller"; import { VoiceController } from "./controller";
const { MockClient, closeContext } = vi.hoisted(() => { const { MockClient, closeContext, createContext } = vi.hoisted(() => {
const closeContext = vi.fn().mockResolvedValue(undefined); const closeContext = vi.fn().mockResolvedValue(undefined);
const createContext = vi.fn(() => ({ state: "running", close: closeContext }));
class MockClient { class MockClient {
static instances: MockClient[] = []; static instances: MockClient[] = [];
readonly options: ClientOptions; readonly options: ClientOptions;
@@ -41,16 +42,17 @@ const { MockClient, closeContext } = vi.hoisted(() => {
sendToolOutput = vi.fn(); sendToolOutput = vi.fn();
join = vi.fn(); join = vi.fn();
} }
return { MockClient, closeContext }; return { MockClient, closeContext, createContext };
}); });
vi.mock("./client", async (importOriginal) => ({ ...(await importOriginal()), RealtimeClient: MockClient })); 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 pendingReasoning: ((value: Response) => void) | undefined;
let controller: VoiceController; let controller: VoiceController;
let fetchMock: Mock<(path: string, init?: RequestInit) => Promise<Response>>; let fetchMock: Mock<(path: string, init?: RequestInit) => Promise<Response>>;
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.clearAllMocks(); vi.clearAllMocks();
createContext.mockReturnValue({ state: "running", close: closeContext });
MockClient.instances = []; MockClient.instances = [];
pendingReasoning = undefined; pendingReasoning = undefined;
vi.stubGlobal("AudioContext", vi.fn()); vi.stubGlobal("AudioContext", vi.fn());
@@ -87,6 +89,83 @@ async function start() {
return client; return client;
} }
describe("voice application lifecycle", () => { 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 () => { it("notifies only meter subscribers when microphone level changes", async () => {
const client = await start(); const client = await start();
const app = vi.fn(); const app = vi.fn();
+3 -15
View File
@@ -56,7 +56,6 @@ export interface AppState {
rag: string; rag: string;
queuePosition: number; queuePosition: number;
joinDeadline: number; joinDeadline: number;
unlock: boolean;
wakeWordEnabled: boolean; wakeWordEnabled: boolean;
} }
@@ -103,7 +102,6 @@ export class VoiceController {
rag: "", rag: "",
queuePosition: 0, queuePosition: 0,
joinDeadline: 0, joinDeadline: 0,
unlock: false,
wakeWordEnabled: false, wakeWordEnabled: false,
}; };
} }
@@ -263,9 +261,6 @@ export class VoiceController {
dismissError() { dismissError() {
this.update({ error: "" }); this.update({ error: "" });
} }
dismissUnlock() {
this.update({ unlock: false });
}
setWakeWordEnabled(wakeWordEnabled: boolean) { setWakeWordEnabled(wakeWordEnabled: boolean) {
this.update({ wakeWordEnabled }); this.update({ wakeWordEnabled });
} }
@@ -275,9 +270,7 @@ export class VoiceController {
} }
this.autoStarted = true; this.autoStarted = true;
const mode = this.state.config.startSessionMode.trim().toUpperCase(); const mode = this.state.config.startSessionMode.trim().toUpperCase();
if (mode === "TALK") { if (mode === "TALK" || mode === "TALKFIRST") {
this.update({ unlock: true });
} else if (mode === "TALKFIRST") {
void this.start(true); void this.start(true);
} }
} }
@@ -289,7 +282,6 @@ export class VoiceController {
return; return;
} }
this.update({ this.update({
unlock: false,
error: "", error: "",
notice: "", notice: "",
status: "connecting", status: "connecting",
@@ -304,8 +296,8 @@ export class VoiceController {
const target = connectionTarget(this.state.config, this.state.settings, this.caller); const target = connectionTarget(this.state.config, this.state.settings, this.caller);
context = preparedContext ?? createAudioContext(); context = preparedContext ?? createAudioContext();
if (automatic && context.state !== "running") { if (automatic && context.state !== "running") {
await context.close(); void context.close().catch(() => undefined);
this.update({ status: "idle", unlock: true }); this.update({ status: "idle", notice: "Toca el micrófono para empezar a conversar." });
return; return;
} }
this.requests = new AbortController(); this.requests = new AbortController();
@@ -417,9 +409,6 @@ export class VoiceController {
// A closed event may already have cleared the client; explicit cancellation is silent. // 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)) { if (!(error instanceof SessionError && error.code === "aborted") && (!this.client || this.client === client)) {
this.reportError(error); this.reportError(error);
if (automatic) {
this.update({ unlock: true });
}
} }
} }
} catch (error) { } catch (error) {
@@ -619,7 +608,6 @@ export class VoiceController {
wakeWordEnabled: false, wakeWordEnabled: false,
muted: false, muted: false,
joinDeadline: 0, joinDeadline: 0,
unlock: false,
messages: this.state.messages.map((message) => messages: this.state.messages.map((message) =>
message.partial ? { ...message, partial: false, interrupted: message.role === "assistant" } : message, message.partial ? { ...message, partial: false, interrupted: message.role === "assistant" } : message,
), ),