fix: give realtime voice a dedicated conversation view
Rebuild & Deploy / rebuild (push) Successful in 3m7s

This commit is contained in:
snowspeeder committed 2026-10-04 10:35:34 -04:00
1 parent 257ab48f0f
commit 8905dc2c2a
9 files changed
+302 -101

No files matched your search

+32 -29
View File
@@ -34,6 +34,7 @@ let tokenAbort = null;
let voiceConversationId = null;
let voiceSessionActive = false;
let lastVoiceState = "";
let voiceError = null;
function escapeHtml(s) {
const d = document.createElement("div");
@@ -140,7 +141,7 @@ async function setConversationMode(mode) {
if (mode === "voice" && previousMode === "text") {
chatInput.blur();
window.setTimeout(() => {
document.querySelector(".workspace").scrollIntoView({ block: "start", behavior: "smooth" });
document.querySelector(".workspace").scrollIntoView({ block: "start", behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
}, 0);
}
@@ -470,7 +471,15 @@ function formatTimestamp(ts) {
return new Date(ts).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
function renderMessage(msg) {
function transcriptFollows() {
const transcript = document.getElementById("conversation");
return transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 100;
}
function scrollTranscript(follow) {
document.getElementById("voiceTranscriptHint").hidden = !!messagesEl.children.length;
if (follow) {const transcript = document.getElementById("conversation"); transcript.scrollTop = transcript.scrollHeight;}
}
function renderMessage(msg, follow = transcriptFollows()) {
const wrap = document.createElement("div");
wrap.className = `message-row ${msg.role}`;
wrap.dataset.ts = formatTimestamp(msg.ts);
@@ -492,26 +501,28 @@ function renderMessage(msg) {
}
wrap.appendChild(bubble);
messagesEl.appendChild(wrap);
messagesEl.scrollTop = messagesEl.scrollHeight;
scrollTranscript(follow);
}
function addMessage(role, text) {
const follow = transcriptFollows();
clearPartials();
const msg = { role, text, ts: Date.now() };
history.push(msg);
persistHistory();
renderMessage(msg);
renderMessage(msg, follow);
return msg;
}
// Partial transcripts render greyed and update in place until a final arrives.
function addPartial(role, text) {
const follow = transcriptFollows();
const rows = messagesEl.querySelectorAll(".message-row.partial");
let last = null;
for (const r of rows) last = r;
if (last && last.dataset.role === role) {
last.querySelector(".bubble").textContent = cleanSpokenText(text);
messagesEl.scrollTop = messagesEl.scrollHeight;
scrollTranscript(follow);
return;
}
const wrap = document.createElement("div");
@@ -527,7 +538,7 @@ function addPartial(role, text) {
bubble.textContent = cleanSpokenText(text);
wrap.appendChild(bubble);
messagesEl.appendChild(wrap);
messagesEl.scrollTop = messagesEl.scrollHeight;
scrollTranscript(follow);
}
function clearPartials() {
@@ -536,8 +547,8 @@ function clearPartials() {
function renderHistory() {
messagesEl.innerHTML = "";
for (const msg of history) renderMessage(msg);
messagesEl.scrollTop = messagesEl.scrollHeight;
for (const msg of history) renderMessage(msg, false);
scrollTranscript(true);
}
clearBtn.addEventListener("click", () => {
@@ -1062,7 +1073,7 @@ function voiceState() {
const ready = agent_connected && agentReady && microphone_enabled && !micMuted;
return { active: voiceSessionActive, state: voiceSessionActive ? (ready ? uiState : (agent_connected && agentReady && micMuted ? "muted" : "connecting")) : "idle",
ready, muted: micMuted, connected, agent_connected, microphone_enabled,
conversation_id: voiceConversationId, cid: voiceConversationId };
conversation_id: voiceConversationId, cid: voiceConversationId, error: voiceError };
}
function publishVoiceState() {
const detail = voiceState();
@@ -1112,6 +1123,7 @@ async function connectConversation() {
const attempt = ++connectionEpoch;
if (window.HopeChat?.running) throw new Error('Wait for the current response or stop it before starting voice.');
voiceConversationId = window.HopeChat?.conversationId || null;
voiceError = null; history = []; messagesEl.replaceChildren(); scrollTranscript(true);
voiceSessionActive = true; publishVoiceState();
startBtn.disabled = true; stopBtn.disabled = false;
const promise = (async () => {
@@ -1139,7 +1151,7 @@ async function connectConversation() {
}
muteBtn.disabled = conversationMode !== "voice"; updateMuteIcon(); setState("listening"); return room;
} catch (error) {
if (attempt === connectionEpoch) { cleanupRoom(); statusEl.textContent = `Error: ${error.message}`; setState("idle"); startBtn.disabled = !HopeAuth.user || HopeAuth.user.status !== "ready"; stopBtn.disabled = true; }
if (attempt === connectionEpoch) { voiceError = error.message; cleanupRoom(); statusEl.textContent = `Error: ${error.message}`; setState("idle"); startBtn.disabled = !HopeAuth.user || HopeAuth.user.status !== "ready"; stopBtn.disabled = true; }
throw error;
} finally { if (attempt === connectionEpoch) connectionPromise = null; }
})();
@@ -1181,24 +1193,13 @@ chatComposer.addEventListener("submit", async (event) => {
const text = chatInput.value.trim();
if (!text || !HopeAuth.user || HopeAuth.user.status !== "ready") return;
sendChatBtn.disabled = true;
chatInput.disabled = true;
try {
if (conversationMode !== "text") await setConversationMode("text");
await connectConversation();
setState("thinking");
await room.localParticipant.sendText(text, { topic: "lk.chat" });
chatInput.value = "";
resizeChatInput();
} catch (err) {
console.error("Text message failed:", err);
statusEl.textContent = `Message not sent: ${err.message}`;
setState(room ? "listening" : "idle");
} finally {
chatInput.disabled = !HopeAuth.user || HopeAuth.user.status !== "ready";
sendChatBtn.disabled = !HopeAuth.user || !chatInput.value.trim();
chatInput.focus();
}
// Compatibility composer delegates to durable HTTP chat, never voice transport.
const input = document.getElementById("primaryInput");
if (voiceSessionActive || input.disabled || input.value.trim()) return;
input.value = text;
input.dispatchEvent(new Event("input", {bubbles:true}));
document.getElementById("primaryComposer").requestSubmit();
chatInput.value = ""; resizeChatInput();
});
// ── Stop ────────────────────────────────────────────────────────────────────
@@ -1225,6 +1226,8 @@ muteBtn.addEventListener("click", toggleMute);
function updateMuteIcon() {
publishVoiceState();
const muted = muteBtn.classList.toggle("muted", micMuted);
muteBtn.textContent = muted ? "Unmute" : "Mute";
muteBtn.setAttribute("aria-pressed", String(muted));
muteBtn.setAttribute("aria-label", muted ? "Unmute microphone" : "Mute microphone");
muteBtn.title = muted ? "Unmute microphone" : "Mute microphone";
}
@@ -1274,7 +1277,7 @@ HopeAuth.onChange((user, changed) => {
voiceSearchEl.value = "";
if (user) loadVoiceCatalog();
historyKey = user ? `hope_history:${location.origin}:${encodeURIComponent(user.id)}` : null;
history = loadHistory(); renderHistory(); progress.clear(); tasksList.replaceChildren(); tasksBadge.hidden = true;
history = []; renderHistory(); progress.clear(); tasksList.replaceChildren(); tasksBadge.hidden = true;
chatInput.value = ""; sendChatBtn.disabled = true;
setTasksOpen(false); closeSheet();
}