refactor: rename API and frontend projects to Vocero

Rename the project directories, Python distribution and import package, and npm package. Update launcher, Docker, tooling, tests, lockfiles, and documentation while preserving existing API contracts.

Make AGENTS.md self-contained and remove external skill and workflow references.
This commit is contained in:
2026-10-06 19:26:55 -03:00
parent e0304b2bdb
commit abd03ed07d
105 changed files with 353 additions and 312 deletions
@@ -0,0 +1,45 @@
import { memo, useEffect, useState } from "react";
import { renderMarkdown, type Message } from "../lib/messages";
function Bubble({ message, userName }: { message: Message; userName: string }) {
const [expiredText, setExpiredText] = useState<string | null>(null);
useEffect(() => {
if (message.partial) {
return;
}
const timer = setTimeout(() => {
setExpiredText(message.text);
}, 5000);
return () => {
clearTimeout(timer);
};
}, [message.text, message.partial]);
if (expiredText === message.text) {
return null;
}
return (
<div className={`bubble ${message.role} in`}>
<div className="bubble-role">
{message.role === "user" ? userName : message.role === "tool" ? "Herramienta" : "Vocero"}
</div>
{message.role === "assistant" ? (
<div
className="bubble-body markdown-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.text) }}
/>
) : (
<div className="bubble-body">{message.role === "tool" ? message.toolName : message.text}</div>
)}
</div>
);
}
export const Bubbles = memo(function Bubbles({ messages, userName }: { messages: Message[]; userName: string }) {
return (
<div className="bubble-stack" aria-hidden="true">
{messages.slice(-3).map((message) => (
<Bubble key={message.id} message={message} userName={userName} />
))}
</div>
);
});
@@ -0,0 +1,140 @@
import { Download, MessageCircle, Wrench } from "lucide-react";
import { memo, useEffect, useRef, useState } from "react";
import { initials } from "../lib/context";
import type { ConnectionStatus } from "../lib/contracts";
import { displayTime, exportConversation, renderMarkdown, type Message } from "../lib/messages";
import { LIVE_STATES } from "../realtime/controller";
const MessageRow = memo(function MessageRow({ message, userName }: { message: Message; userName: string }) {
const [expanded, setExpanded] = useState(false);
const time = displayTime(message.timestamp);
if (message.role === "tool") {
return (
<div className="hist-msg tool">
<div className="hist-role">Llamada a herramienta</div>
<button
className="hist-tool-header"
type="button"
aria-expanded={expanded}
onClick={() => {
setExpanded(!expanded);
}}
>
<Wrench className="hist-tool-icon" />
<span className="hist-tool-name">{message.toolName}</span>
</button>
<div className={`hist-tool-body ${expanded ? "open" : ""}`}>
<div className="hist-tool-label">Entrada</div>
<pre className="hist-tool-block">{message.toolArguments}</pre>
<div className="hist-tool-label">Salida</div>
<pre className="hist-tool-block hist-tool-output">{message.text}</pre>
</div>
</div>
);
}
const data = {
"data-user-name": userName,
"data-user-initials": initials(userName),
"data-asst-name": "Vocero",
"data-timestamp": time,
};
return (
<div className={`hist-msg ${message.role} ${message.interrupted ? "interrupted" : ""}`} {...data}>
<div className="hist-role">{message.role === "user" ? userName : "Vocero"}</div>
{message.role === "assistant" ? (
<div
className={`hist-body markdown-content ${message.partial ? "partial" : ""}`}
{...data}
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.text) }}
/>
) : (
<div className={`hist-body ${message.partial ? "partial" : ""}`} {...data}>
{message.text}
</div>
)}
{message.interrupted && <div className="hist-note">Interrumpido</div>}
</div>
);
});
export const Conversation = memo(function Conversation({
messages,
userName,
status,
white,
}: {
messages: Message[];
userName: string;
status: ConnectionStatus;
white: boolean;
}) {
const history = useRef<HTMLDivElement>(null);
const atBottom = useRef(true);
useEffect(() => {
const element = history.current;
if (element && atBottom.current) {
element.scrollTop = element.scrollHeight;
}
}, [messages]);
const live = LIVE_STATES.has(status);
const waiting = ["connecting", "queued", "your-turn"].includes(status);
return (
<section id="chat-panel" className="chat-panel chat-panel-inline" aria-label="Transcripción de la conversación">
<div className="chat-panel-inner">
<header className="chat-panel-header">
<h2>Conversación</h2>
<div className="chat-header-actions" role="group" aria-label="Acciones de la conversación">
{white && (
<span
className="chat-status-pill"
data-status={live ? "online" : status === "error" ? "error" : waiting ? "waiting" : "idle"}
role="status"
>
<span className="chat-status-dot" aria-hidden="true" />
<span>
{live ? "En línea" : status === "error" ? "Sin conexión" : waiting ? "Conectando" : "Sin sesión"}
</span>
</span>
)}
<button
className="chat-export-btn icon-btn"
type="button"
aria-label="Descargar conversación"
title="Descargar conversación (JSON + TXT)"
disabled={!messages.length}
onClick={() => {
exportConversation(messages, userName);
}}
>
<Download size={18} />
</button>
</div>
</header>
<div
ref={history}
id="chat-history"
className="chat-history"
role="log"
aria-live="polite"
aria-atomic="false"
onScroll={() => {
const element = history.current;
if (element) {
atBottom.current = element.scrollHeight - element.scrollTop - element.clientHeight < 60;
}
}}
>
{messages.length ? (
messages.map((message) => <MessageRow key={message.id} message={message} userName={userName} />)
) : (
<div className="chat-empty">
<MessageCircle aria-hidden="true" />
<span className="chat-empty-title">Aún no hay mensajes</span>
<span className="chat-empty-hint">Toca el círculo y empieza a hablar</span>
</div>
)}
</div>
</div>
</section>
);
});
+46
View File
@@ -0,0 +1,46 @@
import { X } from "lucide-react";
import { useEffect, useId, useRef, type ReactNode } from "react";
interface Props {
title: string;
children: ReactNode;
onClose: () => void;
className?: string;
}
export function Dialog({ title, children, onClose, className = "" }: Props) {
const ref = useRef<HTMLDialogElement>(null);
const titleId = useId();
useEffect(() => {
const dialog = ref.current;
dialog?.showModal();
return () => {
dialog?.close();
};
}, []);
return (
<dialog
ref={ref}
className={`modal ${className}`}
aria-labelledby={titleId}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div className="modal-content">
<header className="modal-header">
<h2 id={titleId}>{title}</h2>
<button type="button" className="icon-btn" aria-label="Cerrar" onClick={onClose}>
<X size={18} />
</button>
</header>
{children}
</div>
</dialog>
);
}
@@ -0,0 +1,337 @@
import { useState } from "react";
import { initials, profileName } from "../lib/context";
import { profileRows } from "../lib/profile";
import { GATE_MAX, GATE_OFF, VOICES } from "../lib/settings";
import { LIVE_STATES, type AppState, type VoiceController } from "../realtime/controller";
import { Dialog } from "./Dialog";
interface Props {
state: AppState;
controller: VoiceController;
onClose: () => void;
}
export function SettingsDialog({ state, controller, onClose }: Props) {
const { config, settings } = state;
const meter = Math.max(
0,
Math.min(100, ((20 * Math.log10(state.level || 0.00001) - GATE_OFF) * 100) / (GATE_MAX - GATE_OFF)),
);
return (
<Dialog title="Configuración" onClose={onClose}>
<form
onSubmit={(event) => {
event.preventDefault();
onClose();
}}
>
<div className="tab-panels">
<div className="tab-panel active">
{config.allowDirect && !config.lb && (
<label className="field">
<span>URL del servidor de voz</span>
<input
type="text"
autoComplete="off"
spellCheck={false}
placeholder="http://localhost:8080"
value={config.s2sUrl || settings.directUrl}
readOnly={!!config.s2sUrl}
onChange={(event) => {
controller.changeSettings({ directUrl: event.target.value });
}}
/>
<small>
{config.s2sUrl
? "El servidor fija esta dirección para todas las conversaciones."
: "Introduce la dirección de tu servidor de voz. La aplicación añade /v1/realtime."}
</small>
</label>
)}
<div className="field-row">
<label className="field">
<span>Voz</span>
<select
value={settings.voice}
onChange={(event) => {
controller.changeSettings({ voice: event.target.value });
}}
>
{VOICES.map((voice) => (
<option key={voice}>{voice}</option>
))}
</select>
</label>
</div>
<div className="field">
<label className="field-head" htmlFor="noise-gate">
Puerta de ruido
<span className="field-value">
{settings.noiseGate <= GATE_OFF ? "Desactivada" : `${String(settings.noiseGate)} dB`}
</span>
</label>
<div className="gate">
<div className="gate-track">
<div className="gate-meter-fill" style={{ width: `${String(meter)}%` }} aria-hidden="true" />
<input
id="noise-gate"
aria-label="Umbral de la puerta de ruido"
type="range"
min={GATE_OFF}
max={GATE_MAX}
step="1"
value={settings.noiseGate}
onChange={(event) => {
controller.changeSettings({ noiseGate: Number(event.target.value) });
}}
/>
</div>
<div className="gate-ends">
<span>Desactivada</span>
<span>−3 dB</span>
</div>
</div>
<small>
Silencia el micrófono por debajo del umbral para evitar enviar ruido. Desliza hasta la izquierda para
desactivarla.
</small>
</div>
<label className="field">
<span>Instrucciones</span>
<textarea
rows={5}
value={config.primaryPrompt || settings.instructions}
readOnly={!!config.primaryPrompt}
onChange={(event) => {
controller.changeSettings({ instructions: event.target.value });
}}
/>
{config.primaryPrompt && <small>Las instrucciones están definidas por el servidor.</small>}
</label>
<div className="field">
<button
type="button"
className="btn primary wide"
disabled={!LIVE_STATES.has(state.status)}
onClick={() => {
onClose();
void controller.restart();
}}
>
Reiniciar conversación con esta configuración
</button>
<small>La voz, las instrucciones y la puerta de ruido se actualizan en la sesión actual.</small>
</div>
</div>
</div>
<footer className="modal-footer">
<button className="btn primary" type="submit">
Guardar
</button>
</footer>
</form>
</Dialog>
);
}
export function RagDialog({ state, controller, onClose }: Props) {
const [text, setText] = useState(state.rag);
const [saved, setSaved] = useState(false);
return (
<Dialog title="Contexto RAG personalizado" onClose={onClose}>
<p className="tools-intro">
Añade documentos, datos o instrucciones que Vocero debe tener en cuenta durante esta conversación. El contexto
se mantiene mientras esta página esté abierta.
</p>
<form
onSubmit={(event) => {
event.preventDefault();
controller.setRag(text);
setSaved(true);
}}
>
<label className="field">
<span>Contexto RAG (texto libre)</span>
<textarea
rows={12}
spellCheck={false}
value={text}
onChange={(event) => {
setText(event.target.value);
setSaved(false);
}}
placeholder="Pega o escribe el contexto que Vocero debe usar al responder…"
/>
</label>
<p className="tool-hint" role="status">
{saved
? "Contexto actualizado para esta conversación."
: state.rag
? `${String(state.rag.length)} caracteres de contexto cargados.`
: "No hay contexto RAG cargado."}
</p>
<footer className="modal-footer">
<button
className="btn secondary"
type="button"
onClick={() => {
setText("");
controller.setRag("");
setSaved(true);
}}
>
Vaciar
</button>
<div className="flex-1" />
<button className="btn primary" type="submit">
Enviar a la sesión
</button>
</footer>
</form>
</Dialog>
);
}
export function ToolsDialog({ state, controller, onClose }: Props) {
return (
<Dialog title="Herramientas" onClose={onClose}>
<p className="tools-intro">
Permite que el asistente actúe durante la conversación. Los cambios se aplican de inmediato.
</p>
<div className="tool-list">
<div className="tool-row">
<div className="tool-info">
<span className="tool-name">Búsqueda web</span>
<span className="tool-desc">Busca información en Google mediante Serper.</span>
</div>
<label className="switch">
<input
type="checkbox"
aria-label="Búsqueda web"
checked={state.settings.webSearch}
onChange={(event) => {
controller.changeSettings({ webSearch: event.target.checked });
}}
/>
<span className="switch-track" aria-hidden="true" />
</label>
</div>
<label className="field tools-key">
<span>Clave de la API de búsqueda</span>
<input
type="password"
autoComplete="off"
value={state.settings.searchKey}
disabled={state.config.search}
onChange={(event) => {
controller.changeSettings({ searchKey: event.target.value });
}}
/>
<small>
{state.config.search
? "La búsqueda está configurada en el servidor."
: "Introduce tu clave de Serper para activar la búsqueda. Se guarda en este navegador."}
</small>
</label>
<div className="tool-row tool-row-sep">
<div className="tool-info">
<span className="tool-name">Herramientas MCP cargadas</span>
<span className="tool-desc">Herramientas publicadas por el perfil remoto del agente.</span>
</div>
</div>
<p className="tool-hint" role="status">
{state.toolsLoading
? "Cargando herramientas…"
: state.toolsError ||
(state.tools.length
? `${String(state.tools.length)} herramientas disponibles.`
: "No hay herramientas remotas disponibles.")}
</p>
<div className="agent-tools-list">
{state.tools.map((tool) => (
<article key={tool.name} className="agent-tool-item">
<strong className="block text-sm">{tool.name}</strong>
<p className="mt-1 text-sm opacity-75">{tool.description}</p>
</article>
))}
</div>
</div>
</Dialog>
);
}
export function ProfileDialog({ state, onClose }: Omit<Props, "controller">) {
const name = profileName(state.profile) || "Usuario";
const rows = profileRows(state.profile);
return (
<Dialog title="Datos del usuario" className="user-profile-modal" onClose={onClose}>
<div className="user-profile-hero">
<div className="user-profile-avatar" aria-hidden="true">
{initials(name)}
</div>
<h3 className="user-profile-title">{name}</h3>
</div>
<dl className="user-profile-metadata">
{rows.map(({ label, value, href }) => (
<div key={label} className="user-profile-row">
<dt className="user-profile-label">{label}</dt>
<dd className="user-profile-value">{href ? <a href={href}>{value}</a> : value}</dd>
</div>
))}
</dl>
</Dialog>
);
}
export function AboutDialog({ onClose }: { onClose: () => void }) {
return (
<Dialog title="Acerca de" className="about-modal" onClose={onClose}>
<p className="tools-intro">
Vocero convierte tu voz en respuestas habladas, en tiempo real. Puedes interrumpirlo para continuar la
conversación.
</p>
<div className="about-pipeline">
<ol className="pipeline">
<li className="pipe-endpoint">Tú hablas</li>
<li className="pipe-stage">
<span className="pipe-tag">STT</span>
<span className="pipe-job">transcribe tu voz</span>
<a
className="pipe-model"
href="https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3"
target="_blank"
rel="noopener noreferrer"
>
nvidia/parakeet-tdt-0.6b-v3
</a>
</li>
<li className="pipe-stage">
<span className="pipe-tag">LLM</span>
<span className="pipe-job">genera una respuesta</span>
<a
className="pipe-model"
href="https://huggingface.co/google/gemma-4-31B-it"
target="_blank"
rel="noopener noreferrer"
>
google/gemma-4-31B-it
</a>
</li>
<li className="pipe-stage">
<span className="pipe-tag">TTS</span>
<span className="pipe-job">la convierte en voz</span>
<a
className="pipe-model"
href="https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
target="_blank"
rel="noopener noreferrer"
>
Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
</a>
</li>
<li className="pipe-endpoint">Vocero responde</li>
</ol>
</div>
</Dialog>
);
}
@@ -0,0 +1,302 @@
import { CircleAlert, Mic, MicOff, Square, Volume2 } from "lucide-react";
import { useEffect, useState, type CSSProperties, type PointerEvent } from "react";
import type { ConnectionStatus } from "../lib/contracts";
import { GATE_MAX, GATE_OFF } from "../lib/settings";
import { ACTIVE_STATES, LIVE_STATES, type AppState, type VoiceController } from "../realtime/controller";
const CAPTIONS: Record<ConnectionStatus, string> = {
idle: "Toca para empezar",
closed: "Toca para empezar",
connecting: "Conectando…",
queued: "Esperando tu turno…",
"your-turn": "¡Es tu turno!",
listening: "Te escucho…",
"user-speaking": "Te escucho…",
processing: "Pensando…",
"ai-speaking": "Vocero está hablando…",
error: "Toca para reintentar",
};
function GateArc({
threshold,
level,
onChange,
}: {
threshold: number;
level: number;
onChange: (value: number) => void;
}) {
const angle = ((135 + ((threshold - GATE_OFF) / (GATE_MAX - GATE_OFF)) * 270) * Math.PI) / 180;
const fraction = Math.max(0, Math.min(1, (20 * Math.log10(level || 0.00001) - GATE_OFF) / (GATE_MAX - GATE_OFF)));
const path = "M 20.3 79.7 A 42 42 0 1 1 79.7 79.7";
function move(event: PointerEvent<SVGSVGElement>) {
const bounds = event.currentTarget.getBoundingClientRect();
const degrees =
(Math.atan2(event.clientY - bounds.top - bounds.height / 2, event.clientX - bounds.left - bounds.width / 2) *
180) /
Math.PI;
const offset = (degrees - 135 + 360) % 360;
const progress = offset > 270 ? (offset > 315 ? 0 : 1) : offset / 270;
onChange(Math.round(GATE_OFF + progress * (GATE_MAX - GATE_OFF)));
}
return (
<svg
className="mic-gate-arc"
viewBox="0 0 100 100"
role="slider"
tabIndex={0}
aria-label="Umbral de la puerta de ruido"
aria-valuemin={GATE_OFF}
aria-valuemax={GATE_MAX}
aria-valuenow={threshold}
aria-valuetext={threshold === GATE_OFF ? "Desactivada" : `${String(threshold)} dB`}
onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId);
move(event);
}}
onPointerMove={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
move(event);
}
}}
onPointerUp={(event) => {
event.currentTarget.releasePointerCapture(event.pointerId);
}}
onKeyDown={(event) => {
const step =
event.key === "ArrowRight" || event.key === "ArrowUp"
? 1
: event.key === "ArrowLeft" || event.key === "ArrowDown"
? -1
: 0;
if (step || event.key === "Home" || event.key === "End") {
event.preventDefault();
onChange(
event.key === "Home"
? GATE_OFF
: event.key === "End"
? GATE_MAX
: Math.max(GATE_OFF, Math.min(GATE_MAX, threshold + step)),
);
}
}}
>
<path className="mga-track" d={path} fill="none" />
<path className="mga-fill" d={path} fill="none" pathLength="1" strokeDasharray={`${String(fraction)} 1`} />
<path className="mga-hit" d={path} fill="none" />
<circle className="mga-handle" r="3" cx={50 + 42 * Math.cos(angle)} cy={50 + 42 * Math.sin(angle)} />
</svg>
);
}
function Waveform({ side }: { side: "left" | "right" }) {
const heights = [0.4, 0.65, 0.9, 1, 0.7, 0.55, 0.3];
return (
<div className={`white-waveform white-waveform-${side}`} aria-hidden="true">
{(side === "left" ? heights : [...heights].reverse()).map((height, index) => (
<span key={index} className={`w-bar wb-${String(index + 1)}`} style={{ "--bar-h": height } as CSSProperties} />
))}
</div>
);
}
function JoinCountdown({ deadline }: { deadline: number }) {
const [now, setNow] = useState(Date.now);
useEffect(() => {
const timer = setInterval(() => {
setNow(Date.now());
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
return (
<p className="circle-subcaption">
Tu lugar se reserva durante {Math.max(0, Math.ceil((deadline - now) / 1000))} s.
</p>
);
}
export function VoiceStage({ state, controller }: { state: AppState; controller: VoiceController }) {
const white = state.config.theme === "white";
const live = LIVE_STATES.has(state.status);
const active = ACTIVE_STATES.has(state.status);
const status = state.status === "closed" ? "idle" : state.status;
const caption =
state.muted && (status === "listening" || status === "user-speaking")
? "Micrófono silenciado"
: CAPTIONS[state.status];
const label = active
? caption
: status === "error"
? "Reintentar conversación de voz"
: "Iniciar conversación de voz";
const muteLabel = state.muted ? "Activar micrófono" : "Silenciar micrófono";
const start = () => {
if (status === "your-turn") {
controller.join();
} else {
void controller.start();
}
};
const disabled = active && status !== "your-turn";
return (
<main className="stage">
{white ? (
<div className={`white-mic-stage ${live ? "live" : ""}`} role="group" aria-label="Control de voz">
<Waveform side="left" />
<button
id="white-mic-circle"
className={`white-mic-circle state-${status}`}
type="button"
aria-label={label}
disabled={disabled}
onClick={start}
>
<span className="wmc-halo" aria-hidden="true" />
<span className="wmc-ring" aria-hidden="true" />
<span className="wmc-core" aria-hidden="true" />
<Mic className="wmc-icon wmc-mic" aria-hidden="true" />
<span className="wmc-icon wmc-spinner" aria-hidden="true" />
</button>
<Waveform side="right" />
<p className="white-mic-caption" role="status">
{caption}
</p>
{state.notice && <p className="white-mic-subcaption">{state.notice}</p>}
{active && (
<div className="white-session-controls" role="group" aria-label="Controles de la conversación">
{live && (
<button
className={`white-mute-btn ${state.muted ? "muted" : ""}`}
type="button"
onClick={() => {
controller.toggleMuted();
}}
aria-pressed={state.muted}
aria-label={muteLabel}
>
{state.muted ? <MicOff /> : <Mic />}
<span>{muteLabel}</span>
</button>
)}
<button
className="white-stop-btn"
type="button"
onClick={() => {
void controller.stop();
}}
>
<Square fill="currentColor" />
<span>Detener</span>
</button>
</div>
)}
</div>
) : (
<>
<div className={`orb-wrap ${active ? "live" : ""}`}>
<div
className={`mic-gate ${20 * Math.log10(state.level || 0.00001) >= state.settings.noiseGate ? "gate-open" : ""}`}
>
{live && (
<GateArc
threshold={state.settings.noiseGate}
level={state.level}
onChange={(noiseGate) => {
controller.changeSettings({ noiseGate });
}}
/>
)}
<button
className={`side-btn ${state.muted ? "muted" : ""}`}
type="button"
disabled={!live}
tabIndex={live ? 0 : -1}
aria-label={muteLabel}
aria-pressed={state.muted}
onClick={() => {
controller.toggleMuted();
}}
>
{state.muted ? <MicOff /> : <Mic />}
</button>
</div>
<button
id="main-circle"
className={`circle state-${status}`}
type="button"
aria-label={label}
disabled={disabled}
onClick={start}
>
<span className="circle-glow" aria-hidden="true" />
<span className="circle-ring" aria-hidden="true" />
<span className="circle-ring-outer" aria-hidden="true" />
<span className="circle-core">
<span className="circle-indicator" aria-hidden="true">
<Mic className="ind ind-mic" />
<CircleAlert className="ind ind-error" />
<span className="ind ind-spinner" />
<span className="ind ind-thinking">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</span>
<span className="ind ind-bars">
{[0, 1, 2, 3, 4].map((number) => (
<span key={number} className="bar" />
))}
</span>
<Volume2 className="ind ind-voice" />
</span>
</span>
</button>
<button
className="side-btn"
type="button"
disabled={!active}
tabIndex={active ? 0 : -1}
aria-label="Finalizar"
onClick={() => {
void controller.stop();
}}
>
<Square fill="currentColor" />
</button>
</div>
<p className="circle-caption" role="status">
{caption}
</p>
{state.notice && <p className="circle-subcaption">{state.notice}</p>}
</>
)}
{(status === "queued" || status === "your-turn") && (
<div className="queue-actions">
{status === "queued" ? (
<p className="circle-subcaption">Posición en la cola: {state.queuePosition}</p>
) : (
<>
<JoinCountdown deadline={state.joinDeadline} />
<button
className="join-queue-btn"
type="button"
onClick={() => {
controller.join();
}}
>
Entrar ahora
</button>
</>
)}
<button
className="leave-queue-btn"
type="button"
onClick={() => {
void controller.stop();
}}
>
Salir de la cola
</button>
</div>
)}
</main>
);
}