fix(ui): simplify Vocero controls and correct session feedback

This commit is contained in:
2026-10-05 11:30:47 -03:00
parent cdbff5cb0e
commit a149f4ef8b
3 changed files with 174 additions and 476 deletions
+117 -214
View File
@@ -628,13 +628,13 @@ function saveTools() {
/** @type {Record<AppState, { caption: string; disabled: boolean }>} */
const STATE_VIEWS = {
idle: { caption: "Toca o di \"Hola Vocero\"", disabled: false },
connecting: { caption: "Conectando", disabled: true },
connecting: { caption: "Conectando…", disabled: true },
queued: { caption: "Buscando un lugar para ti…", disabled: true },
"your-turn": { caption: "¡Es tu turno! 🎉", disabled: true },
listening: { caption: "", disabled: false },
"user-speaking": { caption: "", disabled: false },
processing: { caption: "", disabled: false },
"ai-speaking": { caption: "", disabled: false },
listening: { caption: "Escuchando…", disabled: true },
"user-speaking": { caption: "Te escucho…", disabled: true },
processing: { caption: "Pensando…", disabled: true },
"ai-speaking": { caption: "Vocero está hablando…", disabled: true },
error: { caption: "Toca para reintentar", disabled: false },
};
@@ -666,6 +666,18 @@ const orbWrap = $(".orb-wrap");
const micBtn = $("#mic-btn");
/** @type {HTMLButtonElement} */
const stopBtn = $("#stop-btn");
/** @type {HTMLButtonElement} */
const whiteMicBtn = $("#white-mic-circle");
/** @type {HTMLButtonElement} */
const whiteStopBtn = $("#white-stop-btn");
/** @type {HTMLParagraphElement} */
const whiteMicCaption = $("#white-mic-caption");
/** @type {HTMLParagraphElement} */
const whiteMicSubcaption = $("#white-mic-subcaption");
/** @type {HTMLElement} */
const chatStatus = $("#chat-status");
/** @type {HTMLElement} */
const chatStatusLabel = $("#chat-status-label");
/** @type {HTMLElement} */
const queueActions = $("#queue-actions");
/** @type {HTMLButtonElement} */
@@ -735,7 +747,7 @@ const inputVoice = $("#voice");
const inputInstructions = $("#instructions");
/** @type {HTMLElement} */
const primaryPromptMeta = $("#primary-prompt-meta");
/** @type {HTMLElement} */
/** @type {HTMLButtonElement} */
const identUserName = $("#ident-user-name");
/** @type {HTMLElement} */
// NOTE: #reports-hint now lives inside #reports-sidebar (left sidebar), not
@@ -766,14 +778,6 @@ const userProfileSubtitle = $("#user-profile-subtitle");
/** @type {HTMLElement} */
const userProfileMetadata = $("#user-profile-metadata");
/* ─── Inline chat form (always-visible panel at the bottom of #app) ──── */
/** @type {HTMLFormElement | null} */
const chatForm = /** @type {HTMLFormElement | null} */ ($("#chat-form"));
/** @type {HTMLInputElement | null} */
const chatInput = /** @type {HTMLInputElement | null} */ ($("#chat-input"));
/** @type {HTMLButtonElement | null} */
const chatSendBtn = /** @type {HTMLButtonElement | null} */ ($("#chat-send"));
/** @type {null | { count:number, wrappedLen:number, hints:string[], entries:Array<{sourceScheduleLabel?:string, date?:any}> }} */
let voceroReportsList = null;
let voceroReportsLoaded = false;
@@ -1220,7 +1224,7 @@ function populateUserProfileDialog(profile) {
* and optionally restore focus to the avatar when it closes (a11y).
*/
function openUserProfileDialog() {
if (!userProfileDialog) return;
if (!userProfileDialog || !extractVoceroUserName(voceroUserProfile)) return;
populateUserProfileDialog(/** @type {Record<string, unknown>} */ (voceroUserProfile));
try {
userProfileDialog.showModal();
@@ -1234,17 +1238,18 @@ function openUserProfileDialog() {
function syncVoceroHeaderUser() {
const name = extractVoceroUserName(voceroUserProfile);
const initials = initialsForName(name);
identUserName.hidden = !name;
identUserName.hidden = s2sTheme !== "white" || !name;
identUserName.disabled = !name;
if (name) {
// Show avatar with the 2-letter initials. Full name is reachable via hover
// tooltip (title) and is used by screen readers via aria-label.
identUserName.textContent = initials;
identUserName.title = `Usuario: ${name}`;
identUserName.setAttribute("aria-label", `Usuario: ${name}`);
identUserName.setAttribute("aria-label", `Ver datos de ${name}`);
} else {
identUserName.textContent = "";
identUserName.removeAttribute("title");
identUserName.removeAttribute("aria-label");
identUserName.setAttribute("aria-label", "Ver datos del usuario");
}
if (!reportsHint) return;
@@ -1820,40 +1825,6 @@ function pushToolsToSession() {
// streaming state. The client's events are forwarded to its on* methods.
const chat = new ChatView();
// ── Inline chat form submit (always-visible panel) ─────────────────────────
// User can send free-text messages by typing + pressing Enter (form submit) or
// tapping the gradient paper-plane send button. The text flows into the
// existing realtime client sendUserText() so it behaves exactly the same as a
// voice-transcribed phrase (onUserTranscriptFinished → server → assistant
// response). ChatView already renders bubbles/history via its on* hooks so we
// just forward the text to the socket.
if (chatForm && chatInput) {
chatForm.addEventListener("submit", (e) => {
e.preventDefault();
const raw = chatInput.value;
if (!raw) return;
const text = raw.trim();
if (!text) {
chatInput.value = "";
return;
}
// 1. Send to Vocero realtime server (identical to voice phrase submission).
try {
if (client && typeof client.sendUserText === "function") {
client.sendUserText(text);
}
} catch (err) {
// Never crash the UI if the realtime client rejected; swallow to console.
console.warn("chat-form: sendUserText threw", err);
}
// 2. Reset input. ChatView.onUserTranscriptStarted / onUserTranscriptFinished
// (already wired to client events) will render the ephemeral bubble +
// persistent history row + scroll to bottom automatically.
chatInput.value = "";
chatInput.focus();
});
}
// ── Account / limiter ─────────────────────────────────────────────────────
// Login chip + daily-limit modal (inert unless the deploy is in LB mode). The
// server meters conversation time; the client just heartbeats a live session
@@ -1919,7 +1890,12 @@ function _kickIdleTimer(reason = "activity") {
// user can tell what happened instead of wondering why the orb reset.
circleSubcaption.textContent = "Sesión cerrada tras 30 segundos de silencio. Toca el círculo o repite «Hola Vocero» para empezar de nuevo.";
circleSubcaption.hidden = false;
window.setTimeout(() => { circleSubcaption.hidden = true; }, 8000);
whiteMicSubcaption.textContent = circleSubcaption.textContent;
whiteMicSubcaption.hidden = false;
window.setTimeout(() => {
circleSubcaption.hidden = true;
whiteMicSubcaption.hidden = true;
}, 8000);
})();
}, IDLE_TIMEOUT_MS);
if (DEBUG) {
@@ -1938,14 +1914,41 @@ let client = null;
/** @type {MediaStream | null} */
let micStream = null;
let micMuted = false;
/** @type {Promise<void> | null} */
let teardownPromise = null;
function syncVoiceControls() {
const view = STATE_VIEWS[currentState];
const live = LIVE_STATES.has(currentState);
const disabled = view.disabled || !!teardownPromise;
const label = currentState === "error" ? "Reintentar conversación de voz"
: currentState === "idle" ? "Iniciar conversación de voz" : view.caption;
circleBtn.disabled = disabled;
circleBtn.setAttribute("aria-label", label);
whiteMicBtn.disabled = disabled;
whiteMicBtn.setAttribute("aria-label", label);
whiteMicBtn.className = `white-mic-circle ${STATE_CLASS[currentState]}`;
whiteStopBtn.hidden = !live || s2sTheme !== "white";
whiteStopBtn.disabled = !live || !!teardownPromise;
const waiting = currentState === "queued" || currentState === "your-turn";
chatStatus.dataset.status = live ? "online" : currentState === "error" ? "error"
: currentState === "connecting" || waiting ? "waiting" : "idle";
chatStatusLabel.textContent = live ? "En línea"
: currentState === "connecting" ? "Conectando…"
: currentState === "queued" ? "En cola"
: currentState === "your-turn" ? "Tu turno"
: currentState === "error" ? "Sin conexión" : "Sin sesión";
}
/** @param {AppState} next */
function setState(next) {
const previousState = currentState;
currentState = next;
const view = STATE_VIEWS[next];
circleBtn.disabled = view.disabled;
circleBtn.className = `circle ${STATE_CLASS[next]}`;
if (next !== "error") setCaption(view.caption);
syncVoiceControls();
if (next !== "error" || previousState !== "error") setCaption(view.caption);
const live = LIVE_STATES.has(next);
orbWrap.classList.toggle("live", live);
@@ -1973,6 +1976,8 @@ function setState(next) {
} else {
circleSubcaption.hidden = true;
}
whiteMicSubcaption.textContent = circleSubcaption.textContent;
whiteMicSubcaption.hidden = circleSubcaption.hidden;
// Wake word: armed only when idle/error (no live mic contention with the
// realtime session), and disarmed the moment we move into any other state.
@@ -2022,6 +2027,9 @@ function setCaption(text, kind = "") {
if (!trimmed) parts.push("empty");
if (addIdleCta) parts.push("idle-cta");
circleCaption.className = parts.join(" ");
whiteMicCaption.textContent = trimmed;
whiteMicCaption.classList.toggle("muted", kind === "muted");
whiteMicCaption.classList.toggle("error", kind === "error");
}
function openSettings() {
@@ -2177,22 +2185,9 @@ aboutModal.addEventListener("click", (e) => {
if (e.target === aboutModal) aboutModal.close();
});
/* ─── User Profile dialog handlers (click avatar circle) ───────────── */
// The avatar circle in the brand header: when clicked, opens a native
// <dialog> with the user's profile info. Keyboard accessible: Enter / Space
// also open the dialog. a11y note: we attach role="button" + tabindex via JS
// at load time (so even if markup changes the behavior is preserved).
/* ─── User profile: native button and dialog keyboard behavior ─────── */
if (identUserName) {
identUserName.setAttribute("role", "button");
identUserName.setAttribute("tabindex", "0");
identUserName.style.cursor = "pointer";
identUserName.addEventListener("click", (e) => { e.preventDefault(); openUserProfileDialog(); });
identUserName.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openUserProfileDialog();
}
});
}
// Close via the explicit X button (top-right of the card).
if (userProfileClose) {
@@ -2200,6 +2195,9 @@ if (userProfileClose) {
}
// Click in the backdrop = click whose target is the <dialog> itself.
if (userProfileDialog) {
userProfileDialog.addEventListener("close", () => {
if (!identUserName.hidden) identUserName.focus();
});
userProfileDialog.addEventListener("click", (e) => {
if (e.target === userProfileDialog) userProfileDialog.close();
});
@@ -2724,48 +2722,15 @@ async function fetchConfig() {
void account.refresh();
syncToolsUi();
syncConnectionUi();
// Apply theme runtime: show/hide theme-only elements, wire up white-mic
// button forwarders, unhide chat-form in white theme, etc. Must run after
// `s2sTheme` is set and the data-theme attr has been applied to html.
// Apply the theme and refresh state-dependent controls after config resolves.
_applyThemeRuntime();
}
/**
* Spanish (Castilian) label map for the "Escuchando..." status caption below
* the white-theme blue mic circle. Mirrors every client state.
*/
const WHITE_STATUS_LABELS = Object.freeze({
idle: "Toca para empezar",
connecting: "Conectando…",
queued: "En cola…",
"your-turn":"Escuchando…",
processing: "Pensando…",
"ai-speaking":"Hablando…",
error: "Algo salió mal",
stopped: "Detenido",
});
let _themeRuntimeInitialized = false;
/**
* Apply runtime wiring for the current theme:
* 1. Flip [hidden] on every [data-theme-only="white" / "dark"] element in
* the document so the correct parallel UI is shown.
* 2. Remove the hardcoded `hidden + display:none !important` on #chat-form
* in white theme (we want the text input + send button visible, just
* like the screenshot input bar).
* 3. Click forwarders: #white-mic-circle → real #main-circle,
* #white-stop-btn → real #stop-btn.
* 4. MutationObserver on #main-circle `.className` to mirror every
* `state-*` class live to #white-mic-circle so it swaps its visuals
* (mic icon / stop icon / spinner) exactly in sync with the orb.
* 5. Wrap `setCaption()` so the Spanish label `#white-mic-caption` gets
* updated at the same time as the default orb caption.
* 6. Quick action button listeners: populate chat-input / or send via
* client.sendUserText() when a session is live.
* Called by fetchConfig() AFTER s2sTheme and <html data-theme> are set.
* Re-runs safely on every config fetch (guard `_themeRuntimeInitialized` for
* the one-shot observer / listeners).
* Apply theme visibility, then restore the avatar and session visibility.
* Register theme-specific actions once; setState/setCaption update both views.
*/
function _applyThemeRuntime() {
const isWhite = s2sTheme === "white";
@@ -2793,109 +2758,29 @@ function _applyThemeRuntime() {
}
});
// 2. Chat form input: in WHITE theme, we need the text input visible to
// match the screenshot. The HTML ships with `hidden` + inline
// `display:none !important` because the original dark theme hid it
// (per user request 2026-09-04). Clear both here, restore when leaving.
const chatForm = document.getElementById("chat-form");
if (chatForm) {
if (isWhite) {
chatForm.removeAttribute("hidden");
chatForm.style.removeProperty("display");
} else if (!chatForm.hasAttribute("hidden")) {
// Reapply original dark-theme defaults (preserve existing user edit).
chatForm.setAttribute("hidden", "");
chatForm.style.setProperty("display", "none", "important");
}
}
syncVoceroHeaderUser();
syncVoiceControls();
// One-shot wiring (never re-add listeners/observer on subsequent calls).
// One-shot wiring (never re-add listeners on subsequent calls).
if (_themeRuntimeInitialized) return;
_themeRuntimeInitialized = true;
// 3a. Click forwarder: white mic circle → the real start/stop orb handler.
// Start through the same handler as the dark-theme orb.
const whiteMic = document.getElementById("white-mic-circle");
const realCircle = document.getElementById("main-circle");
if (whiteMic && realCircle) {
whiteMic.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
realCircle.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
if (!whiteMic.disabled) realCircle.click();
});
}
// 3b. Click forwarder: white "Detener" pill → hidden real stop side-btn.
const whiteStop = document.getElementById("white-stop-btn");
const realStop = document.getElementById("stop-btn");
if (whiteStop && realStop) {
whiteStop.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
realStop.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
}
whiteStopBtn.addEventListener("click", async () => {
if (LIVE_STATES.has(currentState)) await teardown();
});
// 4. Live sync of #main-circle className → #white-mic-circle className
// (states: .state-idle, .state-connecting, .state-your-turn, .state-processing,
// .state-ai-speaking, .state-error, .state-queued). White-mic styles
// use these same state-* classes (CSS prefixes with .white-mic-circle.state-*)
// so we simply shadow every class on the orb button to the new button.
if (whiteMic && realCircle && typeof MutationObserver !== "undefined") {
const syncStateClasses = () => {
// Keep the base class + data-theme-only marker but clear/re-add the rest.
const tokens = Array.from(realCircle.classList);
for (const cl of Array.from(whiteMic.classList)) {
if (cl.startsWith("state-")) whiteMic.classList.remove(cl);
}
for (const cl of tokens) {
if (cl.startsWith("state-")) whiteMic.classList.add(cl);
}
// Also show/hide the white stop pill in sync with the real stop-btn
// (which is shown when connected).
if (whiteStop) {
const visible = !realStop.hasAttribute("aria-hidden") || realStop.getAttribute("aria-hidden") === "false";
if (visible) {
whiteStop.style.removeProperty("display");
whiteStop.removeAttribute("hidden");
} else {
whiteStop.setAttribute("hidden", "");
}
}
};
syncStateClasses();
const obs = new MutationObserver(() => syncStateClasses());
obs.observe(realCircle, { attributes: true, attributeFilter: ["class", "aria-hidden"] });
if (realStop) obs.observe(realStop, { attributes: true, attributeFilter: ["aria-hidden", "class"] });
// 5. Set the initial Spanish caption for theme=white + wrap setCaption()
// so any future setCaption() call also updates the Spanish label.
const capWhite = document.getElementById("white-mic-caption");
if (capWhite) {
capWhite.textContent = WHITE_STATUS_LABELS[currentState] || WHITE_STATUS_LABELS.idle;
const origSetCaption = setCaption;
// Re-assign the global (window / module scope var holds function ref) so
// each caller of setCaption(text, kind) now also drives the Spanish label.
// We intentionally DON'T replace the signature — old code keeps working.
(function (prev) {
window.setCaption = function (text, kind) {
prev(text, kind);
// When in white theme we prefer the Spanish state-based label from
// WHITE_STATUS_LABELS whenever possible, falling back to raw text.
if (s2sTheme === "white" && capWhite) {
const fromState = WHITE_STATUS_LABELS[currentState];
capWhite.textContent = fromState || text || WHITE_STATUS_LABELS.idle;
const muted = kind === "muted";
capWhite.classList.toggle("muted", !!muted);
capWhite.classList.toggle("error", kind === "error");
}
};
})(origSetCaption);
}
}
// 6. Quick action buttons for summaries, data lookup and response drafts
// populate the text input or — if a realtime
// session is live — sends it as user text straight away.
// Retained quick actions are only available to an active realtime session.
document.querySelectorAll(".quick-action-btn").forEach(btn => {
btn.addEventListener("click", () => {
const action = btn.getAttribute("data-action") || "";
@@ -2907,16 +2792,7 @@ function _applyThemeRuntime() {
const text = prompts[action] || "";
if (!text) return;
if (client && typeof client.sendUserText === "function") {
// Live session: send directly as if the user typed + pressed enter.
client.sendUserText(text);
} else {
// No session: prefill the text input so they can send manually later.
const inp = document.getElementById("chat-input");
if (inp) {
inp.value = text;
inp.dispatchEvent(new Event("input", { bubbles: true }));
inp.focus({ preventScroll: false });
}
}
});
});
@@ -3301,6 +3177,7 @@ async function doStart(audioContext = null) {
});
c.addEventListener("status", (e) => {
if (client !== c) return;
const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail;
_kickIdleTimer(`client-status:${detail.status}`);
onClientStatus(detail.status);
@@ -3340,15 +3217,19 @@ async function doStart(audioContext = null) {
});
});
c.addEventListener("error", (e) => {
if (client !== c) return;
const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail;
onFatalError(detail.error);
});
c.addEventListener("server-error", (e) => {
// Non-fatal: the backend reported an error mid-session. Log it, keep the
// socket and the conversation alive (the model can recover on its own).
// socket and the conversation alive while showing an actionable message.
const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail;
const msg = detail.error instanceof Error ? detail.error.message : String(detail.error);
console.warn("[main] server error (non-fatal):", msg);
if (client === c) {
setCaption("Vocero no pudo completar la respuesta. Intenta hablar de nuevo.", "error");
}
});
c.addEventListener("session", (e) => {
const info = /** @type {CustomEvent<{ info: import("./ws/s2s-ws-client.js").WsSessionInfo }>} */ (e).detail.info;
@@ -3512,7 +3393,9 @@ function onClientStatus(status) {
setState("ai-speaking");
break;
case "closed":
// teardown() will move us to idle
// An intentional close clears client before closing the socket. A remote
// close still needs to release the mic and clear the online UI.
if (client) void teardown();
break;
case "error":
setState("error");
@@ -3520,7 +3403,21 @@ function onClientStatus(status) {
}
}
async function teardown() {
/** @param {AppState} [finalState] @returns {Promise<void>} */
function teardown(finalState = "idle") {
if (teardownPromise) return teardownPromise;
const closingClient = client;
client = null;
teardownPromise = finishTeardown(closingClient, finalState).finally(() => {
teardownPromise = null;
syncVoiceControls();
});
syncVoiceControls();
return teardownPromise;
}
/** @param {S2sWsRealtimeClient | null} closingClient @param {AppState} finalState */
async function finishTeardown(closingClient, finalState) {
stopHeartbeat();
stopJoinCountdown();
endTrackedSession();
@@ -3531,13 +3428,12 @@ async function teardown() {
// user left queue, limit exceeded) the pending wake-word confirmation would
// be re-injected on the next session. Clear it so we never double-fire.
pendingWakeWordConfirmationText = "";
if (client) {
if (closingClient) {
try {
await client.close();
await closingClient.close();
} catch (err) {
console.warn("[main] error closing client:", err);
}
client = null;
}
if (micStream) {
for (const track of micStream.getTracks()) track.stop();
@@ -3545,7 +3441,7 @@ async function teardown() {
}
micMuted = false;
micBtn.classList.remove("muted");
setState("idle");
setState(finalState);
// Refresh the chip's remaining-today after the budget moved.
if (limiterOn) void account.refresh();
}
@@ -3553,13 +3449,20 @@ async function teardown() {
/** @param {unknown} err */
function onFatalError(err) {
console.error("[main] fatal:", err);
const wasLive = LIVE_STATES.has(currentState);
setState("error");
const message = err instanceof Error ? err.message : String(err);
setCaption(truncateError(message), "error");
void teardown().catch(() => {
const caption = message.includes("micrófono")
? `${truncateError(message)} Toca para reintentar.`
: wasLive
? "Se interrumpió la conexión con Vocero. Revisa tu conexión y toca para reintentar."
: "No se pudo conectar con Vocero. Revisa tu conexión y toca para reintentar.";
setCaption(caption, "error");
const showError = () => {
setState("error");
setCaption(truncateError(message), "error");
});
setCaption(caption, "error");
};
void teardown("error").then(showError, showError);
}
setState("idle");