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
reasoning does not use the native session's tool array.
For `TALK`/`TALKFIRST`, the two-second warmup does not override browser
microphone/audio permissions. If automatic startup fails, use the unlocker
gesture and inspect the permission error rather than increasing the timer
without evidence.
`TALK`/`TALKFIRST` attempt startup as soon as configuration and caller context
are ready. Browser microphone/audio policies can still require a gesture. If
audio is blocked, tap the main microphone button when the inline hint appears.
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
+8 -5
View File
@@ -148,12 +148,15 @@ loading window before the interface allows a generic session.
| Value | Behavior |
|---|---|
| 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 |
| `TALKFIRST` | After a 2-second warmup, attempt automatic start and inject the Spanish wake phrase; microphone failure falls back to the startup dialog |
| `TALK` | Attempt automatic start and inject the Spanish wake phrase as soon as configuration and caller context are ready |
| `TALKFIRST` | Same automatic startup behavior as `TALK` |
The warmup starts after configuration and caller-context loading settle;
optional reports and tools can still be loading. Browser permission and audio
policies can still require a gesture.
Automatic startup runs once per page load, without a startup dialog or artificial
delay; optional reports and tools can still be loading. Browser permission and
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.
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
- 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,
shared form/dialog controls and component-owned layout utilities. Preserve both
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 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,
),