diff --git a/s2s-client/main.js b/s2s-client/main.js
index 7c956c0..0d8f61b 100644
--- a/s2s-client/main.js
+++ b/s2s-client/main.js
@@ -628,13 +628,13 @@ function saveTools() {
/** @type {Record
} */
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} */ (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 | 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
-//